@flun/node-mobile-app 4.1.2 → 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/Android/346/236/204/345/273/272/346/214/207/345/215/227.md +1 -1
- package/CHANGELOG.md +15 -22
- package/README.md +67 -13
- package/bin/cli.js +3 -3
- package/mobileAppConfig.js +17 -4
- package/package.json +1 -1
- package/scripts/copy-files.js +3 -2
- package/src/commands/build.js +105 -99
- package/src/commands/clean.js +12 -6
- package/src/commands/test.js +141 -133
- package/src/config.js +88 -52
- package/src/copy.js +302 -215
- package/src/fingerprint.js +96 -77
- package/src/generate.js +33 -21
- package/src/install.js +176 -162
- package/src/optimize-node-modules.js +5 -4
- package/src/patcher.js +438 -358
- package/src/platform-ios.js +122 -106
- package/src/utils.js +351 -252
- package/template/.prettierrc.js +8 -2
- package/template/App.tsx +98 -87
- package/template/babel.config.js +8 -2
- package/template/eslint.config.js +8 -2
- package/template/metro.config.js +9 -3
- package//350/207/252/345/256/232/344/271/211libnode/346/214/207/345/215/227.md +2 -2
package/src/patcher.js
CHANGED
|
@@ -1,29 +1,12 @@
|
|
|
1
|
-
import
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
if (
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
17
|
+
escapeXml = s => String(s)
|
|
18
|
+
.replace(/&/g, '&')
|
|
19
|
+
.replace(/</g, '<')
|
|
20
|
+
.replace(/>/g, '>')
|
|
21
|
+
.replace(/"/g, '"')
|
|
22
|
+
.replace(/'/g, ''');
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
-
|
|
344
|
-
|
|
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
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
-
|
|
393
|
-
|
|
472
|
+
escapeKotlinString, escapeSwiftString, patchAndroidConfig, patchAppConfig, patchIcon, patchIosConfig,
|
|
473
|
+
patchIosIcon, patchMavenRepositories, patchSigning, resolveAppName
|
|
394
474
|
};
|