@flun/node-mobile-app 4.0.5 → 4.0.7
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" +84 -76
- package/CHANGELOG.md +9 -9
- package/LICENSE +9 -4
- package/README.md +206 -113
- package/bin/cli.js +9 -9
- package/cli-assets/server.js +7 -4
- package/mobileAppConfig.js +32 -32
- package/package.json +5 -5
- package/scripts/copy-files.js +1 -1
- package/src/commands/build.js +9 -4
- package/src/commands/clean.js +3 -2
- package/src/commands/test.js +13 -9
- package/src/config.js +3 -3
- package/src/copy.js +41 -36
- package/src/fingerprint.js +4 -3
- package/src/generate.js +5 -3
- package/src/install.js +38 -27
- package/src/optimize-node-modules.js +10 -5
- package/src/patcher.js +37 -32
- package/src/platform-ios.js +6 -6
- package/src/utils.js +26 -22
- package/template/App.tsx +4 -4
- package/template/Gemfile +1 -1
- package/template/android/app/build.gradle +1 -1
- package/template/android/app/proguard-rules.pro +1 -1
- package/template/android/app/src/main/java/com/myapp/MainActivity.kt +1 -1
- package/template/android/app/src/main/java/com/myapp/MainApplication.kt +1 -1
- package/template/android/app/src/main/res/drawable/rn_edit_text_material.xml +6 -3
- package/template/android/app/src/main/res/raw/keep.xml +1 -1
- package/template/android/app/src/main/res/values/strings.xml +1 -1
- package/template/android/app/src/main/res/values/styles.xml +1 -1
- package/template/android/build.gradle +1 -1
- package/template/android/gradle.properties +1 -1
- package/template/android/gradlew +3 -2
- package/template/android/gradlew.bat +3 -2
- package/template/android/settings.gradle +1 -1
- package/template/app.json +1 -1
- package/template/index.js +2 -2
- package/template/ios/.xcode.env +1 -1
- package/template/ios/MyApp/Images.xcassets/AppIcon.appiconset/Contents.json +1 -1
- package/template/ios/MyApp/Images.xcassets/Contents.json +1 -1
- package/template/ios/MyApp/Info.plist +1 -1
- package/template/ios/MyApp/LaunchScreen.storyboard +30 -12
- package/template/ios/MyApp/PrivacyInfo.xcprivacy +1 -1
- package/template/ios/MyApp.xcodeproj/project.pbxproj +34 -15
- package/template/ios/MyApp.xcodeproj/xcshareddata/xcschemes/MyApp.xcscheme +1 -1
- package/template/ios/Podfile +1 -1
- package/template/package.json +1 -1
- package//350/207/252/345/256/232/344/271/211libnode/346/214/207/345/215/227.md +131 -98
package/src/install.js
CHANGED
|
@@ -8,8 +8,8 @@ import { resolveSdkPath } from './config.js';
|
|
|
8
8
|
import { createHash } from 'crypto';
|
|
9
9
|
const
|
|
10
10
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* 内部:从 libnode 的 node_version.h 读取运行时 Node 主版本号
|
|
12
|
+
* 读不到直接报错退出(说明 libnode 目录不完整,继续执行必失败)
|
|
13
13
|
*/
|
|
14
14
|
resolveRuntimeMajor = (userProjectDir, targetPlatform) => {
|
|
15
15
|
const sub = targetPlatform === 'ios' ? 'ios' : 'android',
|
|
@@ -17,16 +17,16 @@ const
|
|
|
17
17
|
sub, 'libnode', 'include', 'node', 'node_version.h');
|
|
18
18
|
if (!fs.existsSync(header)) {
|
|
19
19
|
console.error(chalk.red(' [错误] 未找到 node_version.h: ' + header));
|
|
20
|
-
console.error(chalk.red(' 说明 libnode
|
|
20
|
+
console.error(chalk.red(' 说明 libnode 目录不完整,请重新安装依赖;')), process.exit(1);
|
|
21
21
|
}
|
|
22
22
|
const m = fs.readFileSync(header, 'utf-8').match(/#define\s+NODE_MAJOR_VERSION\s+(\d+)/);
|
|
23
23
|
if (!m) console.error(chalk.red(' [错误] 无法解析 node_version.h 中的 NODE_MAJOR_VERSION')), process.exit(1);
|
|
24
24
|
return m[1];
|
|
25
25
|
},
|
|
26
26
|
/**
|
|
27
|
-
*
|
|
27
|
+
* 内部:判断用户项目的生产依赖里是否含原生模块(binding.gyp)
|
|
28
28
|
* 按用户根 package.json 的 dependencies 键名,去 userProjectDir/node_modules 下逐个查
|
|
29
|
-
* 不用扫 nodejs-project/node_modules
|
|
29
|
+
* 不用扫 nodejs-project/node_modules:全新构建时它还不存在
|
|
30
30
|
*/
|
|
31
31
|
hasNativeModules = userProjectDir => {
|
|
32
32
|
const pkgPath = path.join(userProjectDir, 'package.json');
|
|
@@ -42,7 +42,7 @@ const
|
|
|
42
42
|
return false;
|
|
43
43
|
},
|
|
44
44
|
/**
|
|
45
|
-
*
|
|
45
|
+
* 内部:定位 @flun/nodejs-mobile-react-native 插件目录
|
|
46
46
|
*/
|
|
47
47
|
findPluginDir = userProjectDir => {
|
|
48
48
|
const candidates = [path.join(userProjectDir, 'node_modules', '@flun', 'nodejs-mobile-react-native')];
|
|
@@ -56,7 +56,8 @@ const
|
|
|
56
56
|
},
|
|
57
57
|
resolveNdkVersion = buildDir => {
|
|
58
58
|
const gradle = path.join(buildDir, 'android', 'build.gradle');
|
|
59
|
-
if (!fs.existsSync(gradle))
|
|
59
|
+
if (!fs.existsSync(gradle))
|
|
60
|
+
console.error(chalk.red(' [错误] 未找到 android/build.gradle: ' + gradle)), process.exit(1);
|
|
60
61
|
const m = fs.readFileSync(gradle, 'utf-8').match(/ndkVersion\s*=\s*"([^"]+)"/);
|
|
61
62
|
if (!m) console.error(chalk.red(' [错误] 无法从 android/build.gradle 解析 ndkVersion')), process.exit(1);
|
|
62
63
|
return m[1];
|
|
@@ -76,7 +77,8 @@ const
|
|
|
76
77
|
if (entry.startsWith('@')) {
|
|
77
78
|
for (const sub of fs.readdirSync(full)) {
|
|
78
79
|
const pkgDir = path.join(full, sub);
|
|
79
|
-
if (fs.statSync(pkgDir).isDirectory()
|
|
80
|
+
if (fs.statSync(pkgDir).isDirectory()
|
|
81
|
+
&& fs.existsSync(path.join(pkgDir, 'binding.gyp'))) result.push(entry + '/' + sub);
|
|
80
82
|
}
|
|
81
83
|
} else if (fs.existsSync(path.join(full, 'binding.gyp'))) result.push(entry);
|
|
82
84
|
}
|
|
@@ -87,7 +89,8 @@ const
|
|
|
87
89
|
if (!sdkPath) console.error(chalk.red(' [错误] 未找到 Android SDK,无法编译原生模块')), process.exit(1);
|
|
88
90
|
const ndkVersion = resolveNdkVersion(buildDir), compileSdk = resolveCompileSdk(buildDir),
|
|
89
91
|
ndkPath = path.join(sdkPath, 'ndk', ndkVersion);
|
|
90
|
-
if (!fs.existsSync(ndkPath))
|
|
92
|
+
if (!fs.existsSync(ndkPath))
|
|
93
|
+
console.error(chalk.red(' [错误] 未找到 NDK ' + ndkVersion + ': ' + ndkPath)), process.exit(1);
|
|
91
94
|
const pluginDir = findPluginDir(userProjectDir);
|
|
92
95
|
if (!pluginDir) console.error(chalk.red(' [错误] 未找到 @flun/nodejs-mobile-react-native 插件')), process.exit(1);
|
|
93
96
|
const libnodeDir = path.join(pluginDir, 'android', 'libnode'),
|
|
@@ -124,7 +127,9 @@ const
|
|
|
124
127
|
path.join(targetDir, 'node_modules', '.bin'), process.env.PATH].filter(Boolean).join(path.delimiter)
|
|
125
128
|
};
|
|
126
129
|
console.log(chalk.cyan(' ℹ️ 编译原生模块 (' + abi + ')...'));
|
|
127
|
-
try {
|
|
130
|
+
try {
|
|
131
|
+
run('npm', ['rebuild', '--build-from-source', '--no-audit', '--no-fund'], { cwd: targetDir, env, quiet: true });
|
|
132
|
+
}
|
|
128
133
|
catch (e) { console.error(chalk.red(' [错误] 原生模块编译失败 (' + abi + '): ' + e.message)), process.exit(1); }
|
|
129
134
|
const missing = findNativePackages(targetDir).filter(name => {
|
|
130
135
|
const pkgDir = path.join(targetDir, 'node_modules', name),
|
|
@@ -144,7 +149,7 @@ const
|
|
|
144
149
|
}
|
|
145
150
|
},
|
|
146
151
|
/**
|
|
147
|
-
*
|
|
152
|
+
* 内部:iOS 的 WebView SSL 补丁
|
|
148
153
|
*/
|
|
149
154
|
patchWebViewSslIos = userProjectDir => {
|
|
150
155
|
const file = path.join(userProjectDir, 'node_modules', 'react-native-webview', 'apple', 'RNCWebViewImpl.m');
|
|
@@ -154,12 +159,13 @@ const
|
|
|
154
159
|
if (c.includes('LOCALHOST_SSL_BYPASS_IOS')) return console.log(chalk.green(' ✓ iOS WebView SSL 补丁已应用'));
|
|
155
160
|
|
|
156
161
|
// 在 "NSString* host = nil;" 那一段之后插入 localhost 放行
|
|
157
|
-
const anchor =
|
|
162
|
+
const anchor =
|
|
163
|
+
' if ([[challenge protectionSpace] authenticationMethod] == NSURLAuthenticationMethodClientCertificate) {',
|
|
158
164
|
idx = c.indexOf(anchor);
|
|
159
165
|
if (idx === -1) return console.warn(chalk.yellow(' ⚠️ RNCWebViewImpl.m 里未找到锚点,跳过 iOS SSL 补丁'));
|
|
160
166
|
|
|
161
167
|
const injected = [
|
|
162
|
-
' // LOCALHOST_SSL_BYPASS_IOS: 对 localhost/127.0.0.1
|
|
168
|
+
' // LOCALHOST_SSL_BYPASS_IOS: 对 localhost/127.0.0.1 放行自签名证书(仅本地测试用)',
|
|
163
169
|
' if (host != nil && ([host isEqualToString:@"127.0.0.1"] || [host isEqualToString:@"localhost"])) {',
|
|
164
170
|
' NSURLCredential *cred = [NSURLCredential credentialForTrust:challenge.protectionSpace.serverTrust];',
|
|
165
171
|
' completionHandler(NSURLSessionAuthChallengeUseCredential, cred);',
|
|
@@ -170,13 +176,13 @@ const
|
|
|
170
176
|
|
|
171
177
|
c = c.substring(0, idx) + injected + c.substring(idx);
|
|
172
178
|
fs.writeFileSync(file, c, 'utf-8');
|
|
173
|
-
console.log(chalk.green(' ✓ 已应用 iOS WebView SSL
|
|
179
|
+
console.log(chalk.green(' ✓ 已应用 iOS WebView SSL 补丁(允许 localhost 自签证书)'));
|
|
174
180
|
},
|
|
175
181
|
/**
|
|
176
182
|
* 装用户项目的生产依赖到 <buildDir>/nodejs-assets/nodejs-project/
|
|
177
183
|
* 只装 dependencies,不装 devDependencies
|
|
178
184
|
* 依赖 + 环境未变化且上次构建完成时跳过,否则按需重装 / 仅优化
|
|
179
|
-
* 快照记录在 buildDir/.deps-snapshot.json
|
|
185
|
+
* 快照记录在 buildDir/.deps-snapshot.json(与桌面版一致)
|
|
180
186
|
*/
|
|
181
187
|
installUserDeps = async (targetDir, userProjectDir, config, buildDir, targetPlatform) => {
|
|
182
188
|
const assetsDir = path.dirname(targetDir), bnmFile = path.join(assetsDir, 'BUILD_NATIVE_MODULES.txt'),
|
|
@@ -187,11 +193,12 @@ const
|
|
|
187
193
|
runtimeMajor = resolveRuntimeMajor(userProjectDir, targetPlatform);
|
|
188
194
|
|
|
189
195
|
fs.writeFileSync(bnmFile, hasNative ? '1' : '0');
|
|
190
|
-
if (hasNative) console.log(chalk.gray(' ℹ️
|
|
196
|
+
if (hasNative) console.log(chalk.gray(' ℹ️ 检测到原生模块(binding.gyp)'));
|
|
191
197
|
|
|
192
198
|
const pkg = JSON.parse(fs.readFileSync(path.join(targetDir, 'package.json'), 'utf-8')), deps = pkg.dependencies || {},
|
|
193
199
|
sortedDeps = Object.keys(deps).sort().reduce((acc, k) => { acc[k] = deps[k]; return acc; }, {}),
|
|
194
|
-
installerHash = createHash('sha256').update(fs.readFileSync(new URL(import.meta.url), 'utf-8'))
|
|
200
|
+
installerHash = createHash('sha256').update(fs.readFileSync(new URL(import.meta.url), 'utf-8'))
|
|
201
|
+
.digest('hex').slice(0, 16),
|
|
195
202
|
snapshotData = { deps: sortedDeps, type: pkg.type || '', hasNative, forceRebuild };
|
|
196
203
|
|
|
197
204
|
let shouldInstall = true, shouldOptimize = thisOptimize, reasons = [];
|
|
@@ -213,7 +220,8 @@ const
|
|
|
213
220
|
if (!installerSame) reasons.push('安装器已更新');
|
|
214
221
|
if (reasons.length > 0) {
|
|
215
222
|
shouldInstall = true, shouldOptimize = thisOptimize;
|
|
216
|
-
console.log(chalk.gray(' ℹ️ ' + (thisOptimize ? '重新安装依赖+优化' : '重新安装依赖')
|
|
223
|
+
console.log(chalk.gray(' ℹ️ ' + (thisOptimize ? '重新安装依赖+优化' : '重新安装依赖')
|
|
224
|
+
+ '(原因: ' + reasons.join(', ') + ')'));
|
|
217
225
|
}
|
|
218
226
|
else if (thisOptimize && !lastOptimized)
|
|
219
227
|
shouldInstall = false, shouldOptimize = true, console.log(chalk.gray(' ℹ️ 依赖与环境未变化,仅需优化'));
|
|
@@ -222,7 +230,8 @@ const
|
|
|
222
230
|
await clearVerified(buildDir);
|
|
223
231
|
return;
|
|
224
232
|
}
|
|
225
|
-
} else
|
|
233
|
+
} else
|
|
234
|
+
console.log(chalk.gray(' ℹ️ ' + (thisOptimize ? '重新安装依赖+优化' : '重新安装依赖') + '(原因: 缓存快照缺失)'));
|
|
226
235
|
|
|
227
236
|
if (shouldInstall) {
|
|
228
237
|
if (fs.existsSync(nodeModulesDir))
|
|
@@ -231,7 +240,7 @@ const
|
|
|
231
240
|
try { fs.rmSync(snapshotPath, { force: true }); } catch { }
|
|
232
241
|
const env = { ...process.env };
|
|
233
242
|
if (hasNative) env.NODEJS_MOBILE_BUILD_NATIVE_MODULES = '1';
|
|
234
|
-
console.log(chalk.cyan('
|
|
243
|
+
console.log(chalk.cyan(' 安装用户生产依赖(nodejs-assets/nodejs-project)...'));
|
|
235
244
|
run('npm', ['install', '--omit=dev', '--no-audit', '--no-fund'], { cwd: targetDir, env, quiet: true });
|
|
236
245
|
const snap = {
|
|
237
246
|
...snapshotData, platform: targetPlatform, abiFilters, runtimeMajor,
|
|
@@ -250,9 +259,9 @@ const
|
|
|
250
259
|
targetPlatform, abiFilters, runtimeMajor, exclude: optimizeCfg.exclude || [], snapshotDir: buildDir
|
|
251
260
|
});
|
|
252
261
|
} catch (err) {
|
|
253
|
-
console.error(chalk.red(' [错误] node_modules
|
|
262
|
+
console.error(chalk.red(' [错误] node_modules 优化失败:' + err.message));
|
|
254
263
|
console.error(chalk.red(' [提示] 可在 mobileAppConfig.js 中设置 optimize: { enabled: false } 关闭优化,'));
|
|
255
|
-
console.error(chalk.red(' 或用 optimize: { exclude: ["包名"] }
|
|
264
|
+
console.error(chalk.red(' 或用 optimize: { exclude: ["包名"] } 排除问题包;')), process.exit(1);
|
|
256
265
|
}
|
|
257
266
|
}
|
|
258
267
|
},
|
|
@@ -270,14 +279,15 @@ const
|
|
|
270
279
|
const before = c;
|
|
271
280
|
c = c.replace(/abiFilters\s*=\s*\[[^\]]*\]/, `abiFilters = [${abiList}]`);
|
|
272
281
|
|
|
273
|
-
if (c !== before) fs.writeFileSync(file, c, 'utf-8'),
|
|
282
|
+
if (c !== before) fs.writeFileSync(file, c, 'utf-8'),
|
|
283
|
+
console.log(chalk.green(` ✓ 已设置插件 abiFilters = [${abiList}]`));
|
|
274
284
|
},
|
|
275
285
|
/**
|
|
276
286
|
* 让 react-native-webview 对 localhost 自签名证书放行
|
|
277
287
|
* Android: patch RNCWebViewClient.java
|
|
278
288
|
* iOS: patch RNCWebViewImpl.m
|
|
279
289
|
* 仅在 config.webview.allowSelfSignedCert === true 时应用
|
|
280
|
-
* 每次 test/build
|
|
290
|
+
* 每次 test/build 都检查(npm 重装 webview 后会覆盖补丁)
|
|
281
291
|
*/
|
|
282
292
|
patchWebViewSsl = (userProjectDir, config) => {
|
|
283
293
|
if (!config.webview || config.webview.allowSelfSignedCert !== true) return;
|
|
@@ -296,9 +306,10 @@ const
|
|
|
296
306
|
const cancelIndex = c.indexOf(old, methodIndex);
|
|
297
307
|
if (cancelIndex !== -1) {
|
|
298
308
|
const newCode = [
|
|
299
|
-
'// LOCALHOST_SSL_BYPASS: 对 localhost/127.0.0.1
|
|
309
|
+
'// LOCALHOST_SSL_BYPASS: 对 localhost/127.0.0.1 放行自签名证书(仅本地测试用)',
|
|
300
310
|
' if (failingUrl != null',
|
|
301
|
-
' && (failingUrl.startsWith("https://127.0.0.1") ||
|
|
311
|
+
' && (failingUrl.startsWith("https://127.0.0.1") || ' +
|
|
312
|
+
' failingUrl.startsWith("https://localhost"))) {',
|
|
302
313
|
' handler.proceed();',
|
|
303
314
|
' return;',
|
|
304
315
|
' }',
|
|
@@ -306,7 +317,7 @@ const
|
|
|
306
317
|
].join('\n');
|
|
307
318
|
c = c.substring(0, cancelIndex) + newCode + c.substring(cancelIndex + old.length);
|
|
308
319
|
fs.writeFileSync(file, c, 'utf-8');
|
|
309
|
-
console.log(chalk.green(' ✓ 已应用 Android WebView SSL
|
|
320
|
+
console.log(chalk.green(' ✓ 已应用 Android WebView SSL 补丁(允许 localhost 自签证书)'));
|
|
310
321
|
}
|
|
311
322
|
}
|
|
312
323
|
}
|
|
@@ -400,7 +400,8 @@ const require = createRequire(import.meta.url), subpath = (...parts) => './' + p
|
|
|
400
400
|
if (cnt > 0) cleared += cnt, pkgItems.push(`${item}(${cnt})`);
|
|
401
401
|
}
|
|
402
402
|
}
|
|
403
|
-
if (process.env.OPTIMIZE_DEBUG && pkgItems.length > 0)
|
|
403
|
+
if (process.env.OPTIMIZE_DEBUG && pkgItems.length > 0)
|
|
404
|
+
console.log(chalk.gray(` ✓ ${pkgRel}: ${pkgItems.join(', ')}`));
|
|
404
405
|
}
|
|
405
406
|
return cleared;
|
|
406
407
|
},
|
|
@@ -465,17 +466,20 @@ const require = createRequire(import.meta.url), subpath = (...parts) => './' + p
|
|
|
465
466
|
},
|
|
466
467
|
delFile = p => {
|
|
467
468
|
try {
|
|
468
|
-
fs.statSync(p).isDirectory() ? fs.rmSync(p, { recursive: true, force: true })
|
|
469
|
+
fs.statSync(p).isDirectory() ? fs.rmSync(p, { recursive: true, force: true })
|
|
470
|
+
: fs.unlinkSync(p), removed++;
|
|
469
471
|
} catch { }
|
|
470
472
|
},
|
|
471
473
|
processDir = d => {
|
|
472
474
|
let entries;
|
|
473
475
|
try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }
|
|
474
476
|
for (const e of entries) {
|
|
475
|
-
const full = path.join(d, e.name),
|
|
477
|
+
const full = path.join(d, e.name),
|
|
478
|
+
expectedPrebuilds = targetPlatform === 'ios' ? ['darwin'] : ['android'],
|
|
476
479
|
prebuildMatch = full.match(/[\\/]prebuilds[\\/](linux|darwin|win32|android)-/i);
|
|
477
480
|
|
|
478
|
-
if (prebuildMatch && !expectedPrebuilds.includes(prebuildMatch[1].toLowerCase()))
|
|
481
|
+
if (prebuildMatch && !expectedPrebuilds.includes(prebuildMatch[1].toLowerCase()))
|
|
482
|
+
{ delFile(full); continue; }
|
|
479
483
|
if (e.isDirectory()) { processDir(full); continue; }
|
|
480
484
|
if (JS_RE.test(e.name)) continue;
|
|
481
485
|
let plat = suffixPlat(e.name), arch = null;
|
|
@@ -532,7 +536,8 @@ const require = createRequire(import.meta.url), subpath = (...parts) => './' + p
|
|
|
532
536
|
},
|
|
533
537
|
pruneIntermediates = async nmDir => {
|
|
534
538
|
let deleted = 0;
|
|
535
|
-
const MID_RE =
|
|
539
|
+
const MID_RE =
|
|
540
|
+
/\.(o|d|mk|stamp|Makefile|iobj|ipdb|pdb|exp|lib|obj|vcxproj|tlog|recipe|lastbuildstate|nativecodeanalysis\.xml)$/i,
|
|
536
541
|
walk = d => {
|
|
537
542
|
for (const f of fs.readdirSync(d, { withFileTypes: true })) {
|
|
538
543
|
const full = path.join(d, f.name);
|
package/src/patcher.js
CHANGED
|
@@ -3,12 +3,12 @@ import fs from 'fs';
|
|
|
3
3
|
import { writeIfChanged } from './utils.js';
|
|
4
4
|
import chalk from 'chalk';
|
|
5
5
|
|
|
6
|
-
// 把字符串中所有非 ASCII 字符转成 \uXXXX
|
|
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
10
|
/**
|
|
11
|
-
* 解析最终 appName
|
|
11
|
+
* 解析最终 appName:
|
|
12
12
|
* 1. config.appName
|
|
13
13
|
* 2. 用户项目 package.json 的 name
|
|
14
14
|
* 3. 'MyApp'
|
|
@@ -24,16 +24,16 @@ const escapeProps = s =>
|
|
|
24
24
|
}
|
|
25
25
|
return 'MyApp';
|
|
26
26
|
},
|
|
27
|
-
/**
|
|
27
|
+
/** 通用字符串转义基础:\\, ", \n, \r, \t */
|
|
28
28
|
escapeStringBase = s => String(s)
|
|
29
29
|
.replace(/\\/g, '\\\\')
|
|
30
30
|
.replace(/"/g, '\\"')
|
|
31
31
|
.replace(/\n/g, '\\n')
|
|
32
32
|
.replace(/\r/g, '\\r')
|
|
33
33
|
.replace(/\t/g, '\\t'),
|
|
34
|
-
/** Kotlin
|
|
34
|
+
/** Kotlin 字符串字面量转义:基础 + 转义 $ */
|
|
35
35
|
escapeKotlinString = s => escapeStringBase(s).replace(/\$/g, '\\$'),
|
|
36
|
-
/** Swift
|
|
36
|
+
/** Swift 字符串字面量转义:基础 + 转义 \( */
|
|
37
37
|
escapeSwiftString = s => escapeStringBase(s).replace(/\(/g, '\\('),
|
|
38
38
|
escapeXml = s => {
|
|
39
39
|
return String(s)
|
|
@@ -91,13 +91,13 @@ const escapeProps = s =>
|
|
|
91
91
|
writeIfChanged(f, c);
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
console.log(chalk.green(` ✓
|
|
94
|
+
console.log(chalk.green(` ✓ 应用信息:${appName} (${appId}) v${versionName}(${versionCode})`));
|
|
95
95
|
},
|
|
96
96
|
/**
|
|
97
|
-
* 把 mobileAppConfig.js 里的 Android 编译配置写入 buildDir
|
|
98
|
-
* - android/build.gradle 的 ext
|
|
99
|
-
* - android/app/build.gradle 的 manifestPlaceholders
|
|
100
|
-
* - AndroidManifest.xml
|
|
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
101
|
*/
|
|
102
102
|
patchAndroidConfig = (buildDir, config) => {
|
|
103
103
|
const a = config.android || {}, rootGradle = path.join(buildDir, 'android', 'build.gradle'),
|
|
@@ -125,7 +125,8 @@ const escapeProps = s =>
|
|
|
125
125
|
if (!c.includes('xmlns:tools=')) {
|
|
126
126
|
c = c.replace(
|
|
127
127
|
/<manifest\s+xmlns:android="[^"]*"/,
|
|
128
|
-
'<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
|
128
|
+
'<manifest xmlns:android="http://schemas.android.com/apk/res/android" ' +
|
|
129
|
+
'xmlns:tools="http://schemas.android.com/tools"'
|
|
129
130
|
);
|
|
130
131
|
}
|
|
131
132
|
// 替换占位符值为实际值
|
|
@@ -150,15 +151,16 @@ const escapeProps = s =>
|
|
|
150
151
|
writeIfChanged(manifestFile, c);
|
|
151
152
|
}
|
|
152
153
|
|
|
153
|
-
console.log(chalk.green(` ✓ Android
|
|
154
|
+
console.log(chalk.green(` ✓ Android 配置:SDK ${a.minSdkVersion}/${a.targetSdkVersion}/${a.compileSdkVersion},
|
|
154
155
|
权限 ${new Set(['android.permission.INTERNET', ...(a.permissions || [])]).size} 项`));
|
|
155
156
|
},
|
|
156
157
|
/**
|
|
157
|
-
* 处理 Release
|
|
158
|
-
*
|
|
159
|
-
*
|
|
158
|
+
* 处理 Release 签名:
|
|
159
|
+
* - config.android.signing 有值 → 写 keystore 路径到 gradle.properties,
|
|
160
|
+
* 加 signingConfigs.release 块,release buildType 用它
|
|
161
|
+
* - 没值 → 不动(release 沿用 debug,可装但不可发布)
|
|
160
162
|
*
|
|
161
|
-
* keystore
|
|
163
|
+
* keystore 路径以用户项目根为基准;
|
|
162
164
|
*/
|
|
163
165
|
patchSigning = (buildDir, userProjectDir, config) => {
|
|
164
166
|
const signing = config.android && config.android.signing;
|
|
@@ -168,16 +170,17 @@ const escapeProps = s =>
|
|
|
168
170
|
const _missing = ['storePassword', 'keyAlias', 'keyPassword'].filter((k) => !signing[k]);
|
|
169
171
|
if (_missing.length > 0) {
|
|
170
172
|
throw new Error(
|
|
171
|
-
'android.signing.keystore
|
|
172
|
-
' 启用签名后,keystore / storePassword / keyAlias / keyPassword
|
|
173
|
-
'
|
|
173
|
+
'android.signing.keystore 已配置,但以下字段为空:' + _missing.join(', ') + '\n' +
|
|
174
|
+
' 启用签名后,keystore / storePassword / keyAlias / keyPassword 四项均为必填;\n' +
|
|
175
|
+
' 如仅需本地测试(不签名上架),可注释掉整个 signing 块--模板默认用 debug keystore 签名;'
|
|
174
176
|
);
|
|
175
177
|
}
|
|
176
178
|
|
|
177
179
|
const keystoreAbs = path.resolve(userProjectDir, signing.keystore);
|
|
178
|
-
if (!fs.existsSync(keystoreAbs))
|
|
180
|
+
if (!fs.existsSync(keystoreAbs))
|
|
181
|
+
return console.warn(chalk.yellow(' ⚠️ 签名 keystore 不存在,跳过签名配置: ' + keystoreAbs));
|
|
179
182
|
|
|
180
|
-
// 1. 写 gradle.properties
|
|
183
|
+
// 1. 写 gradle.properties(追加或覆盖 MYAPP_RELEASE_*)
|
|
181
184
|
const propsFile = path.join(buildDir, 'android', 'gradle.properties');
|
|
182
185
|
if (fs.existsSync(propsFile)) {
|
|
183
186
|
let c = fs.readFileSync(propsFile, 'utf-8');
|
|
@@ -188,7 +191,7 @@ const escapeProps = s =>
|
|
|
188
191
|
c = c.replace(/^\s*MYAPP_RELEASE_KEY_PASSWORD=.*\r?\n/gm, '');
|
|
189
192
|
// 追加新的
|
|
190
193
|
// gradle.properties 按 ISO-8859-1 读取,非 ASCII 字符必须转 \uXXXX 转义,
|
|
191
|
-
//
|
|
194
|
+
// 否则中文路径(如"桌面")会被读成乱码,导致 keystore 找不到;
|
|
192
195
|
const esc = escapeProps(keystoreAbs.replace(/\\/g, '/'));
|
|
193
196
|
c += `\nMYAPP_RELEASE_STORE_FILE=${esc}\n`;
|
|
194
197
|
c += `MYAPP_RELEASE_STORE_PASSWORD=${escapeProps(signing.storePassword || '')}\n`;
|
|
@@ -202,7 +205,7 @@ const escapeProps = s =>
|
|
|
202
205
|
if (fs.existsSync(gradleFile)) {
|
|
203
206
|
let c = fs.readFileSync(gradleFile, 'utf-8');
|
|
204
207
|
|
|
205
|
-
// 加 release
|
|
208
|
+
// 加 release 签名块(先删旧的,避免重复)
|
|
206
209
|
c = c.replace(/\s*release\s*\{\s*storeFile[\s\S]*?\}\r?\n(\s*\}\s*\r?\n)/, '$1');
|
|
207
210
|
// 在 signingConfigs { 之后插入
|
|
208
211
|
c = c.replace(/(signingConfigs\s*\{)/,
|
|
@@ -224,7 +227,7 @@ const escapeProps = s =>
|
|
|
224
227
|
console.log(chalk.green(' ✓ Release 签名已配置: ' + path.basename(keystoreAbs)));
|
|
225
228
|
},
|
|
226
229
|
/**
|
|
227
|
-
*
|
|
230
|
+
* 应用图标:把用户提供的 PNG 缩放到 5 个 Android 密度,覆盖各 mipmap 目录下的 ic_launcher 与 ic_launcher_round
|
|
228
231
|
* 未配置或文件不存在时保持默认
|
|
229
232
|
*/
|
|
230
233
|
patchIcon = async (buildDir, userProjectDir, config) => {
|
|
@@ -258,10 +261,11 @@ const escapeProps = s =>
|
|
|
258
261
|
console.log(chalk.green(` ✓ 图标已应用: ${path.basename(iconAbs)}`));
|
|
259
262
|
},
|
|
260
263
|
/**
|
|
261
|
-
* 写入 iOS
|
|
264
|
+
* 写入 iOS 工程配置:
|
|
262
265
|
* - Info.plist: CFBundleDisplayName
|
|
263
|
-
*
|
|
264
|
-
*
|
|
266
|
+
* - project.pbxproj: PRODUCT_BUNDLE_IDENTIFIER /
|
|
267
|
+
* MARKETING_VERSION / CURRENT_PROJECT_VERSION / IPHONEOS_DEPLOYMENT_TARGET
|
|
268
|
+
* 只在 buildDir 里存在 ios/ 时执行(其余平台跳过);
|
|
265
269
|
*/
|
|
266
270
|
patchIosConfig = (buildDir, userProjectDir, config) => {
|
|
267
271
|
const iosDir = path.join(buildDir, 'ios');
|
|
@@ -271,7 +275,7 @@ const escapeProps = s =>
|
|
|
271
275
|
versionCode = config.versionCode, deploymentTarget = (config.ios?.deploymentTarget) || '15.1',
|
|
272
276
|
plist = path.join(iosDir, 'MyApp', 'Info.plist'), pbx = path.join(iosDir, 'MyApp.xcodeproj', 'project.pbxproj'),
|
|
273
277
|
appDelegate = path.join(iosDir, 'MyApp', 'AppDelegate.swift');
|
|
274
|
-
// 1. Info.plist
|
|
278
|
+
// 1. Info.plist:CFBundleDisplayName + ATS (对应 Android 的 usesCleartextTraffic)
|
|
275
279
|
if (fs.existsSync(plist)) {
|
|
276
280
|
let c = fs.readFileSync(plist, 'utf-8');
|
|
277
281
|
|
|
@@ -336,12 +340,13 @@ const escapeProps = s =>
|
|
|
336
340
|
writeIfChanged(appDelegate, c);
|
|
337
341
|
}
|
|
338
342
|
|
|
339
|
-
console.log(chalk.green(` ✓ iOS
|
|
343
|
+
console.log(chalk.green(` ✓ iOS 配置:${appName} (${appId}) v${versionName}(${versionCode})` +
|
|
344
|
+
`, target ${deploymentTarget}`));
|
|
340
345
|
},
|
|
341
346
|
/**
|
|
342
|
-
* iOS
|
|
347
|
+
* iOS 应用图标:把用户提供的 PNG 缩放到 9 个尺寸,写入 AppIcon.appiconset
|
|
343
348
|
* 同时改写 Contents.json 加 filename 字段
|
|
344
|
-
*
|
|
349
|
+
* 未配置或文件不存在时保持默认(无图标,Xcode 会用系统占位)
|
|
345
350
|
*/
|
|
346
351
|
patchIosIcon = async (buildDir, userProjectDir, config) => {
|
|
347
352
|
const iconPath = config.ios && config.ios.icon;
|
|
@@ -354,7 +359,7 @@ const escapeProps = s =>
|
|
|
354
359
|
if (!fs.existsSync(iconDir)) return;
|
|
355
360
|
|
|
356
361
|
const { Jimp } = await import('jimp'), source = await Jimp.read(iconAbs),
|
|
357
|
-
// Apple
|
|
362
|
+
// Apple 规范:每项 (idiom, size, scale) 对应的像素尺寸与文件名
|
|
358
363
|
variants = [
|
|
359
364
|
{ idiom: 'iphone', size: '20x20', scale: '2x', px: 40, name: 'Icon-App-20x20@2x.png' },
|
|
360
365
|
{ idiom: 'iphone', size: '20x20', scale: '3x', px: 60, name: 'Icon-App-20x20@3x.png' },
|
package/src/platform-ios.js
CHANGED
|
@@ -6,7 +6,7 @@ import chalk from 'chalk';
|
|
|
6
6
|
const SCHEME = 'MyApp',
|
|
7
7
|
/** iOS 相关命令只能在 macOS 上跑 */
|
|
8
8
|
ensureMac = () => {
|
|
9
|
-
if (process.platform !== 'darwin') throw new Error('iOS 构建只能在 macOS
|
|
9
|
+
if (process.platform !== 'darwin') throw new Error('iOS 构建只能在 macOS 上执行(当前平台: ' + process.platform + ')');
|
|
10
10
|
},
|
|
11
11
|
/** 检查 xcodebuild 是否存在 */
|
|
12
12
|
checkIosTools = () => {
|
|
@@ -14,14 +14,14 @@ const SCHEME = 'MyApp',
|
|
|
14
14
|
if (r.status !== 0) throw new Error('未找到 xcodebuild,请安装 Xcode 并运行 xcode-select --install');
|
|
15
15
|
},
|
|
16
16
|
/**
|
|
17
|
-
* 执行 pod install
|
|
17
|
+
* 执行 pod install(若 Pods/ 已存在且 Podfile.lock 未变,cocoapods 会自动跳过)
|
|
18
18
|
*/
|
|
19
19
|
podInstall = iosDir => {
|
|
20
20
|
console.log(chalk.cyan(' pod install ...')), run('pod', ['install'], { cwd: iosDir, inherit: true });
|
|
21
21
|
},
|
|
22
22
|
/**
|
|
23
23
|
* 把 config.ios.signing.certificate 导入临时 keychain,供 xcodebuild 签名用
|
|
24
|
-
* 返回 { keychainPath, keychainPassword } 或 null
|
|
24
|
+
* 返回 { keychainPath, keychainPassword } 或 null(未配置 / 文件缺失)
|
|
25
25
|
*/
|
|
26
26
|
setupSigningKeychain = (buildDir, userProjectDir, config) => {
|
|
27
27
|
const signing = config.ios && config.ios.signing;
|
|
@@ -37,7 +37,7 @@ const SCHEME = 'MyApp',
|
|
|
37
37
|
ensureDir(keychainDir);
|
|
38
38
|
const keychainPath = path.join(keychainDir, 'build.keychain-db'), keychainPassword = 'nmapp' + Date.now();
|
|
39
39
|
|
|
40
|
-
// 清掉同名的旧 keychain
|
|
40
|
+
// 清掉同名的旧 keychain(重复构建时)
|
|
41
41
|
if (fs.existsSync(keychainPath)) fs.rmSync(keychainPath, { force: true });
|
|
42
42
|
console.log(chalk.cyan(' 创建临时 keychain 并导入证书 ...'));
|
|
43
43
|
run('security', ['create-keychain', '-p', keychainPassword, keychainPath], { quiet: true });
|
|
@@ -64,7 +64,7 @@ const SCHEME = 'MyApp',
|
|
|
64
64
|
*/
|
|
65
65
|
xcodebuildArchive = (buildDir, config, release, userProjectDir) => {
|
|
66
66
|
const iosDir = path.join(buildDir, 'ios'), workspace = path.join(iosDir, SCHEME + '.xcworkspace');
|
|
67
|
-
if (!fs.existsSync(workspace)) throw new Error('未找到 ' + workspace + '
|
|
67
|
+
if (!fs.existsSync(workspace)) throw new Error('未找到 ' + workspace + '(pod install 是否成功?)');
|
|
68
68
|
|
|
69
69
|
const archiveDir = path.join(iosDir, 'build');
|
|
70
70
|
ensureDir(archiveDir);
|
|
@@ -97,7 +97,7 @@ const SCHEME = 'MyApp',
|
|
|
97
97
|
ensureDir(exportDir);
|
|
98
98
|
const archivePath = path.join(iosDir, 'build', SCHEME + '.xcarchive'), method = release ? 'ad-hoc' : 'development',
|
|
99
99
|
signing = (config.ios && config.ios.signing) || {}, teamId = signing.teamId || '',
|
|
100
|
-
// provisioningProfile 路径 → 取 basename
|
|
100
|
+
// provisioningProfile 路径 → 取 basename(去扩展名),写进 exportOptions.plist
|
|
101
101
|
profilePath = signing.provisioningProfile ? path.resolve(userProjectDir, signing.provisioningProfile) : '',
|
|
102
102
|
profileName = profilePath ? path.basename(profilePath).replace(/\.mobileprovision$/i, '') : '',
|
|
103
103
|
plistPath = path.join(iosDir, 'build', 'exportOptions.plist'),
|