@flun/node-mobile-app 1.0.1

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.
Files changed (69) hide show
  1. package/Android /346/236/204/345/273/272/346/214/207/345/215/227.md" +315 -0
  2. package/LICENSE +29 -0
  3. package/README.md +448 -0
  4. package/bin/cli.js +49 -0
  5. package/cli-assets/server.js +37 -0
  6. package/mobileAppConfig.js +146 -0
  7. package/package.json +88 -0
  8. package/scripts/copy-files.js +37 -0
  9. package/src/commands/build.js +109 -0
  10. package/src/commands/clean.js +25 -0
  11. package/src/commands/test.js +108 -0
  12. package/src/config.js +112 -0
  13. package/src/copy.js +235 -0
  14. package/src/fingerprint.js +102 -0
  15. package/src/generate.js +102 -0
  16. package/src/install.js +144 -0
  17. package/src/patcher.js +374 -0
  18. package/src/platform-ios.js +142 -0
  19. package/src/utils.js +131 -0
  20. package/template/.prettierrc.js +5 -0
  21. package/template/.watchmanconfig +22 -0
  22. package/template/@types/nodejs-mobile-react-native/index.d.ts +9 -0
  23. package/template/App.tsx +108 -0
  24. package/template/Gemfile +17 -0
  25. package/template/android/app/build.gradle +127 -0
  26. package/template/android/app/debug.keystore +0 -0
  27. package/template/android/app/proguard-rules.pro +10 -0
  28. package/template/android/app/src/main/AndroidManifest.xml +27 -0
  29. package/template/android/app/src/main/java/com/myapp/MainActivity.kt +22 -0
  30. package/template/android/app/src/main/java/com/myapp/MainApplication.kt +27 -0
  31. package/template/android/app/src/main/res/drawable/rn_edit_text_material.xml +37 -0
  32. package/template/android/app/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
  33. package/template/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png +0 -0
  34. package/template/android/app/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
  35. package/template/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png +0 -0
  36. package/template/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
  37. package/template/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png +0 -0
  38. package/template/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
  39. package/template/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png +0 -0
  40. package/template/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
  41. package/template/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png +0 -0
  42. package/template/android/app/src/main/res/raw/keep.xml +1 -0
  43. package/template/android/app/src/main/res/values/strings.xml +3 -0
  44. package/template/android/app/src/main/res/values/styles.xml +9 -0
  45. package/template/android/build.gradle +21 -0
  46. package/template/android/gradle/wrapper/gradle-wrapper.jar +0 -0
  47. package/template/android/gradle/wrapper/gradle-wrapper.properties +7 -0
  48. package/template/android/gradle.properties +49 -0
  49. package/template/android/gradlew +248 -0
  50. package/template/android/gradlew.bat +98 -0
  51. package/template/android/settings.gradle +6 -0
  52. package/template/app.json +4 -0
  53. package/template/babel.config.js +3 -0
  54. package/template/eslint.config.js +6 -0
  55. package/template/index.js +12 -0
  56. package/template/ios/.xcode.env +11 -0
  57. package/template/ios/MyApp/AppDelegate.swift +55 -0
  58. package/template/ios/MyApp/Images.xcassets/AppIcon.appiconset/Contents.json +53 -0
  59. package/template/ios/MyApp/Images.xcassets/Contents.json +6 -0
  60. package/template/ios/MyApp/Info.plist +74 -0
  61. package/template/ios/MyApp/LaunchScreen.storyboard +47 -0
  62. package/template/ios/MyApp/PrivacyInfo.xcprivacy +37 -0
  63. package/template/ios/MyApp.xcodeproj/project.pbxproj +475 -0
  64. package/template/ios/MyApp.xcodeproj/xcshareddata/xcschemes/MyApp.xcscheme +88 -0
  65. package/template/ios/Podfile +34 -0
  66. package/template/metro.config.js +18 -0
  67. package/template/mobileApp.runtime.d.ts +16 -0
  68. package/template/package.json +18 -0
  69. package//350/207/252/345/256/232/344/271/211libnode/346/214/207/345/215/227.md +447 -0
package/src/patcher.js ADDED
@@ -0,0 +1,374 @@
1
+ import path from 'path';
2
+ import fs from 'fs';
3
+ import { writeIfChanged } from './utils.js';
4
+
5
+ /**
6
+ * 解析最终 appName:
7
+ * 1. config.appName
8
+ * 2. 用户项目 package.json 的 name
9
+ * 3. 'MyApp'
10
+ */
11
+ const resolveAppName = (userProjectDir, config) => {
12
+ if (config.appName) return String(config.appName);
13
+ const pkgPath = path.join(userProjectDir, 'package.json');
14
+ if (fs.existsSync(pkgPath)) {
15
+ try {
16
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
17
+ if (pkg.name) return String(pkg.name);
18
+ } catch { }
19
+ }
20
+ return 'MyApp';
21
+ },
22
+ /** 通用字符串转义基础:\\, ", \n, \r, \t */
23
+ escapeStringBase = s => String(s)
24
+ .replace(/\\/g, '\\\\')
25
+ .replace(/"/g, '\\"')
26
+ .replace(/\n/g, '\\n')
27
+ .replace(/\r/g, '\\r')
28
+ .replace(/\t/g, '\\t'),
29
+ /** Kotlin 字符串字面量转义:基础 + 转义 $ */
30
+ escapeKotlinString = s => escapeStringBase(s).replace(/\$/g, '\\$'),
31
+ /** Swift 字符串字面量转义:基础 + 转义 \( */
32
+ escapeSwiftString = s => escapeStringBase(s).replace(/\(/g, '\\('),
33
+ escapeXml = s => {
34
+ return String(s)
35
+ .replace(/&/g, '&')
36
+ .replace(/</g, '&lt;')
37
+ .replace(/>/g, '&gt;')
38
+ .replace(/"/g, '&quot;')
39
+ .replace(/'/g, '&apos;');
40
+ },
41
+ /**
42
+ * 把 mobileAppConfig.js 里的应用信息写入 buildDir 的 RN 工程
43
+ * - android/app/build.gradle: namespace / applicationId / versionCode / versionName
44
+ * - app.json: name / displayName
45
+ * - res/values/strings.xml: app_name
46
+ * - MainActivity.kt / MainApplication.kt: package 声明
47
+ */
48
+ patchAppConfig = (buildDir, userProjectDir, config) => {
49
+ const appName = resolveAppName(userProjectDir, config), appId = config.appId,
50
+ versionCode = config.versionCode, versionName = config.versionName,
51
+ gradleFile = path.join(buildDir, 'android', 'app', 'build.gradle'),
52
+ appJsonFile = path.join(buildDir, 'app.json'),
53
+ stringsFile = path.join(buildDir, 'android', 'app', 'src', 'main', 'res', 'values', 'strings.xml'),
54
+ ktFiles = [
55
+ path.join(buildDir, 'android', 'app', 'src', 'main', 'java', 'com', 'myapp', 'MainActivity.kt'),
56
+ path.join(buildDir, 'android', 'app', 'src', 'main', 'java', 'com', 'myapp', 'MainApplication.kt'),
57
+ ];
58
+ // 1. build.gradle
59
+ if (fs.existsSync(gradleFile)) {
60
+ let c = fs.readFileSync(gradleFile, 'utf-8');
61
+ c = c.replace(/namespace\s+"[^"]+"/, 'namespace "' + appId + '"');
62
+ c = c.replace(/applicationId\s+"[^"]+"/, 'applicationId "' + appId + '"');
63
+ c = c.replace(/versionCode\s+\d+/, 'versionCode ' + versionCode);
64
+ c = c.replace(/versionName\s+"[^"]+"/, 'versionName "' + versionName + '"');
65
+ writeIfChanged(gradleFile, c);
66
+ }
67
+ // 2. app.json
68
+ if (fs.existsSync(appJsonFile)) {
69
+ const obj = { name: appName, displayName: appName };
70
+ writeIfChanged(appJsonFile, JSON.stringify(obj, null, 2) + '\n');
71
+ }
72
+ // 3. strings.xml
73
+ if (fs.existsSync(stringsFile)) {
74
+ let c = fs.readFileSync(stringsFile, 'utf-8');
75
+ c = c.replace(/(<string name="app_name">)[^<]*(<\/string>)/, '$1' + escapeXml(appName) + '$2');
76
+ writeIfChanged(stringsFile, c);
77
+ }
78
+ // 4. MainActivity.kt / MainApplication.kt 的 package 声明
79
+ for (const f of ktFiles) {
80
+ if (!fs.existsSync(f)) continue;
81
+ let c = fs.readFileSync(f, 'utf-8');
82
+ c = c.replace(/^package\s+[\w.]+/m, 'package ' + appId);
83
+ // MainActivity.kt: getMainComponentName 要和 app.json.name 一致
84
+ c = c.replace(
85
+ /(override\s+fun\s+getMainComponentName\(\)\s*:\s*String\s*=\s*)"[^"]*"/, `$1"${escapeKotlinString(appName)}"`
86
+ );
87
+ writeIfChanged(f, c);
88
+ }
89
+
90
+ console.log(` ✓ 应用信息:${appName} (${appId}) v${versionName}(${versionCode})`);
91
+ },
92
+ /**
93
+ * 把 mobileAppConfig.js 里的 Android 编译配置写入 buildDir:
94
+ * - android/build.gradle 的 ext 块:minSdkVersion / targetSdkVersion / compileSdkVersion / buildToolsVersion
95
+ * - android/app/build.gradle 的 manifestPlaceholders:usesCleartextTraffic
96
+ * - AndroidManifest.xml:permissions(去重,始终含 INTERNET)
97
+ */
98
+ patchAndroidConfig = (buildDir, config) => {
99
+ const a = config.android || {}, rootGradle = path.join(buildDir, 'android', 'build.gradle'),
100
+ manifestFile = path.join(buildDir, 'android', 'app', 'src', 'main', 'AndroidManifest.xml');
101
+ // 1. 根 build.gradle 的 ext 块
102
+ if (fs.existsSync(rootGradle)) {
103
+ let c = fs.readFileSync(rootGradle, 'utf-8');
104
+ if (Number.isInteger(a.minSdkVersion))
105
+ c = c.replace(/minSdkVersion\s*=\s*\d+/, 'minSdkVersion = ' + a.minSdkVersion);
106
+ if (Number.isInteger(a.targetSdkVersion))
107
+ c = c.replace(/targetSdkVersion\s*=\s*\d+/, 'targetSdkVersion = ' + a.targetSdkVersion);
108
+ if (Number.isInteger(a.compileSdkVersion))
109
+ c = c.replace(/compileSdkVersion\s*=\s*\d+/, 'compileSdkVersion = ' + a.compileSdkVersion);
110
+ if (a.buildToolsVersion)
111
+ c = c.replace(/buildToolsVersion\s*=\s*"[^"]+"/, 'buildToolsVersion = "' + a.buildToolsVersion + '"');
112
+ writeIfChanged(rootGradle, c);
113
+ }
114
+ // 2. AndroidManifest.xml: 直接写死 usesCleartextTraffic + tools:replace
115
+ // 不用占位符,因为 AGP release 构建在 targetSdk>=28 时会强制 false
116
+ if (fs.existsSync(manifestFile)) {
117
+ let c = fs.readFileSync(manifestFile, 'utf-8');
118
+ const ct = a.usesCleartextTraffic !== false ? 'true' : 'false';
119
+
120
+ // manifest 根标签加 xmlns:tools
121
+ if (!c.includes('xmlns:tools=')) {
122
+ c = c.replace(
123
+ /<manifest\s+xmlns:android="[^"]*"/,
124
+ '<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools"'
125
+ );
126
+ }
127
+ // 替换占位符值为实际值
128
+ c = c.replace(/android:usesCleartextTraffic="\$\{usesCleartextTraffic\}"/, `android:usesCleartextTraffic="${ct}"`);
129
+ // application 标签加 tools:replace
130
+ if (!/tools:replace="[^"]*android:usesCleartextTraffic/.test(c))
131
+ c = c.replace(/(<application)/, '$1\n tools:replace="android:usesCleartextTraffic"');
132
+
133
+ writeIfChanged(manifestFile, c);
134
+ }
135
+ // 3. AndroidManifest.xml 权限
136
+ if (fs.existsSync(manifestFile)) {
137
+ let c = fs.readFileSync(manifestFile, 'utf-8');
138
+ const perms = new Set(['android.permission.INTERNET']);
139
+ for (const p of a.permissions || []) perms.add(p);
140
+
141
+ // 删掉现有的所有 <uses-permission ... /> 行
142
+ c = c.replace(/^\s*<uses-permission[^>]*\/>\s*\r?\n/gm, '');
143
+ // 在 <application 前插入
144
+ const block = Array.from(perms).map((p) => ` <uses-permission android:name="${p}" />`).join('\n');
145
+ c = c.replace(/(\r?\n)(\s*<application)/, `\n${block}\n\n$2`);
146
+ writeIfChanged(manifestFile, c);
147
+ }
148
+
149
+ console.log(` ✓ Android 配置:SDK ${a.minSdkVersion}/${a.targetSdkVersion}/${a.compileSdkVersion},
150
+ 权限 ${new Set(['android.permission.INTERNET', ...(a.permissions || [])]).size} 项`);
151
+ },
152
+ /**
153
+ * 处理 Release 签名:
154
+ * - config.android.signing 有值 → 写 keystore 路径到 gradle.properties,加 signingConfigs.release 块,release buildType 用它
155
+ * - 没值 → 不动(release 沿用 debug,可装但不可发布)
156
+ *
157
+ * keystore 路径以用户项目根为基准。
158
+ */
159
+ patchSigning = (buildDir, userProjectDir, config) => {
160
+ const signing = config.android && config.android.signing;
161
+ if (!signing || !signing.keystore) return; // 未配置签名 → 保持默认
162
+
163
+ const keystoreAbs = path.resolve(userProjectDir, signing.keystore);
164
+ if (!fs.existsSync(keystoreAbs)) return console.warn(' ⚠️ 签名 keystore 不存在,跳过签名配置:', keystoreAbs);
165
+
166
+ // 1. 写 gradle.properties(追加或覆盖 MYAPP_RELEASE_*)
167
+ const propsFile = path.join(buildDir, 'android', 'gradle.properties');
168
+ if (fs.existsSync(propsFile)) {
169
+ let c = fs.readFileSync(propsFile, 'utf-8');
170
+ // 删掉旧的 4 行
171
+ c = c.replace(/^\s*MYAPP_RELEASE_STORE_FILE=.*\r?\n/gm, '');
172
+ c = c.replace(/^\s*MYAPP_RELEASE_STORE_PASSWORD=.*\r?\n/gm, '');
173
+ c = c.replace(/^\s*MYAPP_RELEASE_KEY_ALIAS=.*\r?\n/gm, '');
174
+ c = c.replace(/^\s*MYAPP_RELEASE_KEY_PASSWORD=.*\r?\n/gm, '');
175
+ // 追加新的
176
+ const esc = keystoreAbs.replace(/\\/g, '/');
177
+ c += `\nMYAPP_RELEASE_STORE_FILE=${esc}\n`;
178
+ c += `MYAPP_RELEASE_STORE_PASSWORD=${signing.storePassword || ''}\n`;
179
+ c += `MYAPP_RELEASE_KEY_ALIAS=${signing.keyAlias || ''}\n`;
180
+ c += `MYAPP_RELEASE_KEY_PASSWORD=${signing.keyPassword || ''}\n`;
181
+ writeIfChanged(propsFile, c);
182
+ }
183
+
184
+ // 2. app/build.gradle: 加 signingConfigs.release,并让 buildTypes.release 用它
185
+ const gradleFile = path.join(buildDir, 'android', 'app', 'build.gradle');
186
+ if (fs.existsSync(gradleFile)) {
187
+ let c = fs.readFileSync(gradleFile, 'utf-8');
188
+
189
+ // 加 release 签名块(先删旧的,避免重复)
190
+ c = c.replace(/\s*release\s*\{\s*storeFile[\s\S]*?\}\r?\n(\s*\}\s*\r?\n)/, '$1');
191
+ // 在 signingConfigs { 之后插入
192
+ c = c.replace(/(signingConfigs\s*\{)/,
193
+ `$1
194
+ release {
195
+ storeFile file(MYAPP_RELEASE_STORE_FILE)
196
+ storePassword MYAPP_RELEASE_STORE_PASSWORD
197
+ keyAlias MYAPP_RELEASE_KEY_ALIAS
198
+ keyPassword MYAPP_RELEASE_KEY_PASSWORD
199
+ }`
200
+ );
201
+
202
+ // 让 release buildType 用 signingConfigs.release
203
+ // 只替换 release { 之后的第一个 signingConfig signingConfigs.debug
204
+ c = c.replace(/(release\s*\{\s*\n\s*signingConfig\s+)signingConfigs\.debug/, '$1signingConfigs.release');
205
+ writeIfChanged(gradleFile, c);
206
+ }
207
+
208
+ console.log(' ✓ Release 签名已配置:', path.basename(keystoreAbs));
209
+ },
210
+ /**
211
+ * 应用图标:把用户提供的 PNG 缩放到 5 个 Android 密度,覆盖各 mipmap 目录下的 ic_launcher 与 ic_launcher_round
212
+ * 未配置或文件不存在时保持默认
213
+ */
214
+ patchIcon = async (buildDir, userProjectDir, config) => {
215
+ const iconPath = config.android && config.android.icon;
216
+ if (!iconPath) return;
217
+
218
+ const iconAbs = path.resolve(userProjectDir, iconPath);
219
+ if (!fs.existsSync(iconAbs)) return console.warn(' ⚠️ 图标文件不存在,保持默认:', iconAbs);
220
+
221
+ const { Jimp } = await import('jimp'),
222
+ densities = [
223
+ { dir: 'mipmap-mdpi', size: 48 },
224
+ { dir: 'mipmap-hdpi', size: 72 },
225
+ { dir: 'mipmap-xhdpi', size: 96 },
226
+ { dir: 'mipmap-xxhdpi', size: 144 },
227
+ { dir: 'mipmap-xxxhdpi', size: 192 }
228
+ ], resDir = path.join(buildDir, 'android', 'app', 'src', 'main', 'res'), source = await Jimp.read(iconAbs);
229
+
230
+ for (const { dir, size } of densities) {
231
+ const targetDir = path.join(resDir, dir);
232
+ if (!fs.existsSync(targetDir)) continue;
233
+
234
+ const img = source.clone();
235
+ img.resize({ w: size, h: size });
236
+ const buf = await img.getBuffer('image/png');
237
+
238
+ fs.writeFileSync(path.join(targetDir, 'ic_launcher.png'), buf);
239
+ fs.writeFileSync(path.join(targetDir, 'ic_launcher_round.png'), buf);
240
+ }
241
+
242
+ console.log(` ✓ 图标已应用: ${path.basename(iconAbs)}`);
243
+ },
244
+ /**
245
+ * 写入 iOS 工程配置:
246
+ * - Info.plist: CFBundleDisplayName
247
+ * - project.pbxproj: PRODUCT_BUNDLE_IDENTIFIER / MARKETING_VERSION / CURRENT_PROJECT_VERSION / IPHONEOS_DEPLOYMENT_TARGET
248
+ * 只在 buildDir 里存在 ios/ 时执行(其余平台跳过)。
249
+ */
250
+ patchIosConfig = (buildDir, userProjectDir, config) => {
251
+ const iosDir = path.join(buildDir, 'ios');
252
+ if (!fs.existsSync(iosDir)) return;
253
+
254
+ const appName = resolveAppName(userProjectDir, config), appId = config.appId, versionName = config.versionName,
255
+ versionCode = config.versionCode, deploymentTarget = (config.ios?.deploymentTarget) || '15.1',
256
+ plist = path.join(iosDir, 'MyApp', 'Info.plist'), pbx = path.join(iosDir, 'MyApp.xcodeproj', 'project.pbxproj'),
257
+ appDelegate = path.join(iosDir, 'MyApp', 'AppDelegate.swift');
258
+ // 1. Info.plist:CFBundleDisplayName + ATS (对应 Android 的 usesCleartextTraffic)
259
+ if (fs.existsSync(plist)) {
260
+ let c = fs.readFileSync(plist, 'utf-8');
261
+
262
+ // CFBundleDisplayName
263
+ c = c.replace(/(<key>CFBundleDisplayName<\/key>\s*<string>)[^<]*(<\/string>)/, '$1' + escapeXml(appName) + '$2');
264
+ // ATS: usesCleartextTraffic=true → 放行本地 HTTP; false → 全部禁用
265
+ const allowCleartext = config.android.usesCleartextTraffic !== false, tab = '\t', atsBlock = allowCleartext
266
+ ? [
267
+ '<key>NSAppTransportSecurity</key>',
268
+ tab + '<dict>',
269
+ tab + tab + '<key>NSAllowsArbitraryLoads</key>',
270
+ tab + tab + '<false/>',
271
+ tab + tab + '<key>NSAllowsLocalNetworking</key>',
272
+ tab + tab + '<true/>',
273
+ tab + tab + '<key>NSExceptionDomains</key>',
274
+ tab + tab + '<dict>',
275
+ tab + tab + tab + '<key>localhost</key>',
276
+ tab + tab + tab + '<dict>',
277
+ tab + tab + tab + tab + '<key>NSExceptionAllowsInsecureHTTPLoads</key>',
278
+ tab + tab + tab + tab + '<true/>',
279
+ tab + tab + tab + tab + '<key>NSIncludesSubdomains</key>',
280
+ tab + tab + tab + tab + '<true/>',
281
+ tab + tab + tab + '</dict>',
282
+ tab + tab + tab + '<key>127.0.0.1</key>',
283
+ tab + tab + tab + '<dict>',
284
+ tab + tab + tab + tab + '<key>NSExceptionAllowsInsecureHTTPLoads</key>',
285
+ tab + tab + tab + tab + '<true/>',
286
+ tab + tab + tab + '</dict>',
287
+ tab + tab + '</dict>',
288
+ tab + '</dict>',
289
+ ].join('\n')
290
+ : [
291
+ '<key>NSAppTransportSecurity</key>',
292
+ tab + '<dict>',
293
+ tab + tab + '<key>NSAllowsArbitraryLoads</key>',
294
+ tab + tab + '<false/>',
295
+ tab + tab + '<key>NSAllowsLocalNetworking</key>',
296
+ tab + tab + '<false/>',
297
+ tab + '</dict>',
298
+ ].join('\n');
299
+
300
+ c = c.replace(
301
+ /<key>NSAppTransportSecurity<\/key>[\s\S]*?(?=<key>NSLocationWhenInUseUsageDescription<\/key>)/, atsBlock + '\n\t'
302
+ );
303
+ writeIfChanged(plist, c);
304
+ }
305
+
306
+ // 2. project.pbxproj
307
+ if (fs.existsSync(pbx)) {
308
+ let c = fs.readFileSync(pbx, 'utf-8');
309
+ c = c.replace(/PRODUCT_BUNDLE_IDENTIFIER = "[^"]+";/g, `PRODUCT_BUNDLE_IDENTIFIER = "${appId}";`);
310
+ c = c.replace(/MARKETING_VERSION = [^;]+;/g, `MARKETING_VERSION = ${versionName};`);
311
+ c = c.replace(/CURRENT_PROJECT_VERSION = [^;]+;/g, `CURRENT_PROJECT_VERSION = ${versionCode};`);
312
+ c = c.replace(/IPHONEOS_DEPLOYMENT_TARGET = [^;]+;/g, `IPHONEOS_DEPLOYMENT_TARGET = ${deploymentTarget};`);
313
+ writeIfChanged(pbx, c);
314
+ }
315
+
316
+ // 3. AppDelegate.swift: withModuleName 要和 app.json.name 一致
317
+ if (fs.existsSync(appDelegate)) {
318
+ let c = fs.readFileSync(appDelegate, 'utf-8');
319
+ c = c.replace(/(withModuleName:\s*)"[^"]*"/, `$1"${escapeSwiftString(appName)}"`);
320
+ writeIfChanged(appDelegate, c);
321
+ }
322
+
323
+ console.log(` ✓ iOS 配置:${appName} (${appId}) v${versionName}(${versionCode}), target ${deploymentTarget}`);
324
+ },
325
+ /**
326
+ * iOS 应用图标:把用户提供的 PNG 缩放到 9 个尺寸,写入 AppIcon.appiconset
327
+ * 同时改写 Contents.json 加 filename 字段
328
+ * 未配置或文件不存在时保持默认(无图标,Xcode 会用系统占位)
329
+ */
330
+ patchIosIcon = async (buildDir, userProjectDir, config) => {
331
+ const iconPath = config.ios && config.ios.icon;
332
+ if (!iconPath) return;
333
+
334
+ const iconAbs = path.resolve(userProjectDir, iconPath);
335
+ if (!fs.existsSync(iconAbs)) return console.warn(' ⚠️ iOS 图标文件不存在,保持默认:', iconAbs);
336
+
337
+ const iconDir = path.join(buildDir, 'ios', 'MyApp', 'Images.xcassets', 'AppIcon.appiconset');
338
+ if (!fs.existsSync(iconDir)) return;
339
+
340
+ const { Jimp } = await import('jimp'), source = await Jimp.read(iconAbs),
341
+ // Apple 规范:每项 (idiom, size, scale) 对应的像素尺寸与文件名
342
+ variants = [
343
+ { idiom: 'iphone', size: '20x20', scale: '2x', px: 40, name: 'Icon-App-20x20@2x.png' },
344
+ { idiom: 'iphone', size: '20x20', scale: '3x', px: 60, name: 'Icon-App-20x20@3x.png' },
345
+ { idiom: 'iphone', size: '29x29', scale: '2x', px: 58, name: 'Icon-App-29x29@2x.png' },
346
+ { idiom: 'iphone', size: '29x29', scale: '3x', px: 87, name: 'Icon-App-29x29@3x.png' },
347
+ { idiom: 'iphone', size: '40x40', scale: '2x', px: 80, name: 'Icon-App-40x40@2x.png' },
348
+ { idiom: 'iphone', size: '40x40', scale: '3x', px: 120, name: 'Icon-App-40x40@3x.png' },
349
+ { idiom: 'iphone', size: '60x60', scale: '2x', px: 120, name: 'Icon-App-60x60@2x.png' },
350
+ { idiom: 'iphone', size: '60x60', scale: '3x', px: 180, name: 'Icon-App-60x60@3x.png' },
351
+ { idiom: 'ios-marketing', size: '1024x1024', scale: '1x', px: 1024, name: 'Icon-App-1024x1024@1x.png' },
352
+ ], contentsFile = path.join(iconDir, 'Contents.json'),
353
+ contents = {
354
+ images: variants.map(v => ({ idiom: v.idiom, scale: v.scale, size: v.size, filename: v.name, })),
355
+ info: { author: 'xcode', version: 1 }
356
+ };
357
+
358
+ for (const v of variants) {
359
+ const img = source.clone();
360
+ img.resize({ w: v.px, h: v.px });
361
+ const buf = await img.getBuffer('image/png');
362
+ fs.writeFileSync(path.join(iconDir, v.name), buf);
363
+ }
364
+
365
+ // 改写 Contents.json,给每项加 filename
366
+ writeIfChanged(contentsFile, JSON.stringify(contents, null, 2) + '\n');
367
+ console.log(` ✓ iOS 图标已应用: ${path.basename(iconAbs)}`);
368
+ }
369
+
370
+ // ===== 导出 =====
371
+ export {
372
+ resolveAppName, escapeKotlinString, escapeSwiftString, patchAppConfig, patchAndroidConfig, patchSigning,
373
+ patchIcon, patchIosConfig, patchIosIcon,
374
+ };
@@ -0,0 +1,142 @@
1
+ import path from 'path';
2
+ import fs from 'fs';
3
+ import { run, ensureDir } from './utils.js';
4
+
5
+ const SCHEME = 'MyApp',
6
+ /** iOS 相关命令只能在 macOS 上跑 */
7
+ ensureMac = () => {
8
+ if (process.platform !== 'darwin') throw new Error('iOS 构建只能在 macOS 上执行(当前平台: ' + process.platform + ')');
9
+ },
10
+ /** 检查 xcodebuild 是否存在 */
11
+ checkIosTools = () => {
12
+ const r = run('which', ['xcodebuild'], { stdio: 'pipe' });
13
+ if (r.status !== 0) throw new Error('未找到 xcodebuild,请安装 Xcode 并运行 xcode-select --install');
14
+ },
15
+ /**
16
+ * 执行 pod install(若 Pods/ 已存在且 Podfile.lock 未变,cocoapods 会自动跳过)
17
+ */
18
+ podInstall = iosDir => {
19
+ console.log(' pod install ...'), run('pod', ['install'], { cwd: iosDir });
20
+ },
21
+ /**
22
+ * 把 config.ios.signing.certificate 导入临时 keychain,供 xcodebuild 签名用
23
+ * 返回 { keychainPath, keychainPassword } 或 null(未配置 / 文件缺失)
24
+ */
25
+ setupSigningKeychain = (buildDir, userProjectDir, config) => {
26
+ const signing = config.ios && config.ios.signing;
27
+ if (!signing || !signing.certificate) return null;
28
+
29
+ const certPath = path.resolve(userProjectDir, signing.certificate);
30
+ if (!fs.existsSync(certPath)) {
31
+ console.warn(' ⚠️ iOS 签名证书不存在,跳过:', certPath);
32
+ return null;
33
+ }
34
+
35
+ const keychainDir = path.join(buildDir, 'ios', 'build', 'signing');
36
+ ensureDir(keychainDir);
37
+ const keychainPath = path.join(keychainDir, 'build.keychain-db'), keychainPassword = 'nmapp' + Date.now();
38
+
39
+ // 清掉同名的旧 keychain(重复构建时)
40
+ if (fs.existsSync(keychainPath)) fs.rmSync(keychainPath, { force: true });
41
+ console.log(' 创建临时 keychain 并导入证书 ...');
42
+ run('security', ['create-keychain', '-p', keychainPassword, keychainPath]);
43
+ run('security', ['set-keychain-settings', '-lut', '3600', keychainPath]);
44
+ run('security', ['unlock-keychain', '-p', keychainPassword, keychainPath]);
45
+ run('security', [
46
+ 'import', certPath,
47
+ '-k', keychainPath,
48
+ '-P', signing.certificatePassword || '',
49
+ '-T', '/usr/bin/codesign',
50
+ '-T', '/usr/bin/security',
51
+ ]);
52
+ run('security', [
53
+ 'set-key-partition-list',
54
+ '-S', 'apple-tool:,apple:,codesign:',
55
+ '-s',
56
+ '-k', keychainPassword,
57
+ keychainPath,
58
+ ]);
59
+
60
+ return { keychainPath, keychainPassword };
61
+ },
62
+ /**
63
+ * xcodebuild archive
64
+ * 返回 xcarchive 路径
65
+ */
66
+ xcodebuildArchive = (buildDir, config, release, userProjectDir) => {
67
+ const iosDir = path.join(buildDir, 'ios'), workspace = path.join(iosDir, SCHEME + '.xcworkspace');
68
+ if (!fs.existsSync(workspace)) throw new Error('未找到 ' + workspace + '(pod install 是否成功?)');
69
+
70
+ const archiveDir = path.join(iosDir, 'build');
71
+ ensureDir(archiveDir);
72
+ const archivePath = path.join(archiveDir, SCHEME + '.xcarchive'), configName = release ? 'Release' : 'Debug',
73
+ teamId = (config.ios && config.ios.signing && config.ios.signing.teamId) || '',
74
+ args = [
75
+ '-workspace', workspace,
76
+ '-scheme', SCHEME,
77
+ '-configuration', configName,
78
+ '-destination', 'generic/platform=iOS',
79
+ '-archivePath', archivePath,
80
+ 'archive',
81
+ ];
82
+
83
+ if (teamId) args.push('DEVELOPMENT_TEAM=' + teamId);
84
+
85
+ // 若配置了 certificate,导入临时 keychain 并让 xcodebuild 使用
86
+ const keychain = setupSigningKeychain(buildDir, userProjectDir, config);
87
+ if (keychain) args.push('OTHER_CODE_SIGN_FLAGS=--keychain ' + keychain.keychainPath);
88
+
89
+ console.log(' xcodebuild archive ...'), run('xcodebuild', args, { cwd: iosDir });
90
+ return archivePath;
91
+ },
92
+ /**
93
+ * xcodebuild -exportArchive,导出 .ipa
94
+ * 返回 .ipa 路径
95
+ */
96
+ xcodebuildExport = (buildDir, config, release, outDir, userProjectDir) => {
97
+ const iosDir = path.join(buildDir, 'ios'), exportDir = path.join(iosDir, 'build', 'export');
98
+ ensureDir(exportDir);
99
+ const archivePath = path.join(iosDir, 'build', SCHEME + '.xcarchive'), method = release ? 'ad-hoc' : 'development',
100
+ signing = (config.ios && config.ios.signing) || {}, teamId = signing.teamId || '',
101
+ // provisioningProfile 路径 → 取 basename(去扩展名),写进 exportOptions.plist
102
+ profilePath = signing.provisioningProfile ? path.resolve(userProjectDir, signing.provisioningProfile) : '',
103
+ profileName = profilePath ? path.basename(profilePath).replace(/\.mobileprovision$/i, '') : '',
104
+ plistPath = path.join(iosDir, 'build', 'exportOptions.plist'),
105
+ plist = [
106
+ '<?xml version="1.0" encoding="UTF-8"?>',
107
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
108
+ '<plist version="1.0">',
109
+ '<dict>',
110
+ ' <key>method</key>',
111
+ ' <string>' + method + '</string>',
112
+ ' <key>stripSwiftSymbols</key>',
113
+ ' <true/>',
114
+ ' <key>compileBitcode</key>',
115
+ ' <false/>',
116
+ teamId ? ' <key>teamID</key>' : '',
117
+ teamId ? ' <string>' + teamId + '</string>' : '',
118
+ profileName ? ' <key>provisioningProfiles</key>' : '',
119
+ profileName ? ' <dict>' : '',
120
+ profileName ? ' <key>' + config.appId + '</key>' : '',
121
+ profileName ? ' <string>' + profileName + '</string>' : '',
122
+ profileName ? ' </dict>' : '',
123
+ '</dict>',
124
+ '</plist>',
125
+ ].filter(Boolean).join('\n');
126
+ fs.writeFileSync(plistPath, plist, 'utf-8'); // 生成 exportOptions.plist
127
+
128
+ console.log(' xcodebuild -exportArchive ...');
129
+ run('xcodebuild', [
130
+ '-exportArchive',
131
+ '-archivePath', archivePath,
132
+ '-exportOptionsPlist', plistPath,
133
+ '-exportPath', exportDir,
134
+ ], { cwd: iosDir });
135
+
136
+ // 找 .ipa
137
+ const files = fs.readdirSync(exportDir).filter(f => f.endsWith('.ipa'));
138
+ if (files.length === 0) throw new Error('导出目录里没有 .ipa: ' + exportDir);
139
+ return path.join(exportDir, files[0]);
140
+ };
141
+
142
+ export { ensureMac, checkIosTools, podInstall, xcodebuildArchive, xcodebuildExport };
package/src/utils.js ADDED
@@ -0,0 +1,131 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { spawnSync } from 'child_process';
4
+
5
+ const ensureDir = dir => {
6
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
7
+ },
8
+ removeDir = dir => {
9
+ if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
10
+ },
11
+ isExcluded = (name, patterns) => {
12
+ for (let p of patterns) {
13
+ p = p.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/$/, '');
14
+ if (p === name) return true;
15
+ if (p.includes('*')) {
16
+ const escaped = p.replace(/[.+?^${}()|[\]\\/]/g, '\\$&').replace(/\*/g, '.*');
17
+ if (new RegExp('^' + escaped + '$').test(name)) return true;
18
+ }
19
+ }
20
+ return false;
21
+ },
22
+ /**
23
+ * 递归复制,exclude 支持 'node_modules/'、'*.log'、'.git/'
24
+ */
25
+ copyDir = (src, dest, exclude = []) => {
26
+ ensureDir(dest);
27
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
28
+ const name = entry.name;
29
+ if (isExcluded(name, exclude)) continue;
30
+ const s = path.join(src, name), d = path.join(dest, name);
31
+ if (entry.isDirectory()) copyDir(s, d, exclude);
32
+ else if (entry.isFile()) fs.copyFileSync(s, d);
33
+ }
34
+ },
35
+ readJson = file => JSON.parse(fs.readFileSync(file, 'utf-8')),
36
+ writeJson = (file, obj) => fs.writeFileSync(file, JSON.stringify(obj, null, 2) + '\n', 'utf-8'),
37
+ /**
38
+ * 内容不同才写文件,避免不必要的 mtime 变化触发下游(Gradle/Metro)重编
39
+ * 返回 true 表示写入了,false 表示内容相同跳过
40
+ */
41
+ writeIfChanged = (file, content) => {
42
+ if (fs.existsSync(file)) {
43
+ try {
44
+ if (fs.readFileSync(file, 'utf-8') === content) return false;
45
+ } catch { /* 读失败就当需要写 */ }
46
+ }
47
+ fs.writeFileSync(file, content, 'utf-8');
48
+ return true;
49
+ },
50
+ /**
51
+ * 大小和 mtime 都一致才跳过复制,否则复制
52
+ * 用于用户项目 → nodejs-project 的增量复制
53
+ */
54
+ copyFileIfChanged = (src, dst) => {
55
+ if (fs.existsSync(dst)) {
56
+ try {
57
+ const s = fs.statSync(src), d = fs.statSync(dst);
58
+ if (s.size === d.size && Math.floor(s.mtimeMs) <= Math.floor(d.mtimeMs)) return false;
59
+ } catch { /* 出错就复制 */ }
60
+ }
61
+ fs.copyFileSync(src, dst);
62
+ return true;
63
+ },
64
+ run = (bin, args, opts = {}) => {
65
+ const r = spawnSync(bin, args, { stdio: 'inherit', shell: true, ...opts });
66
+ if (r.status !== 0) throw new Error(`${bin} ${args.join(' ')} 退出码 ${r.status}`);
67
+ return r;
68
+ },
69
+ runQuiet = (bin, args, opts = {}) => spawnSync(bin, args, { encoding: 'utf-8', shell: true, ...opts }),
70
+ runSilent = (bin, args, opts = {}) => {
71
+ const r = spawnSync(bin, args, { encoding: 'utf-8', shell: true, ...opts });
72
+ if (r.status !== 0) {
73
+ if (r.stdout) process.stderr.write(r.stdout);
74
+ if (r.stderr) process.stderr.write(r.stderr);
75
+ throw new Error(`${bin} ${args.join(' ')} 退出码 ${r.status}`);
76
+ }
77
+ return r;
78
+ },
79
+ /**
80
+ * 定位 adb(配置 > 环境变量 > 默认路径 > PATH)
81
+ */
82
+ findAdb = config => {
83
+ // 1. 用户显式配置的路径优先
84
+ const configured = config?.android?.adbPath;
85
+ if (configured) {
86
+ if (fs.existsSync(configured)) return configured;
87
+ console.warn('⚠️ config.android.adbPath 不存在,回退到自动查找: ' + configured);
88
+ }
89
+ // 2. 环境变量 ANDROID_HOME / ANDROID_SDK_ROOT
90
+ for (const env of ['ANDROID_HOME', 'ANDROID_SDK_ROOT']) {
91
+ const base = process.env[env];
92
+ if (base) {
93
+ const p = path.join(base, 'platform-tools', process.platform === 'win32' ? 'adb.exe' : 'adb');
94
+ if (fs.existsSync(p)) return p;
95
+ }
96
+ }
97
+ // 3. 默认路径猜测(Windows / macOS / Linux)
98
+ const home = process.env.USERPROFILE || process.env.HOME || '', localAppData = process.env.LOCALAPPDATA || '',
99
+ candidates = [
100
+ path.join(localAppData, 'Android', 'Sdk', 'platform-tools', 'adb.exe'),
101
+ path.join(home, 'Library', 'Android', 'sdk', 'platform-tools', 'adb'),
102
+ path.join(home, 'Android', 'Sdk', 'platform-tools', 'adb'),
103
+ ];
104
+ for (const c of candidates) if (c && fs.existsSync(c)) return c;
105
+ return 'adb';
106
+ },
107
+ /**
108
+ * 定位 hermesc(Hermes 编译器,用于把 JS bundle 编成字节码)
109
+ */
110
+ findHermesc = userProjectDir => {
111
+ const base = path.join(userProjectDir, 'node_modules', 'hermes-compiler', 'hermesc'), platform = process.platform;
112
+ let sub;
113
+ if (platform === 'win32') sub = ['win64-bin', 'hermesc.exe'];
114
+ else if (platform === 'darwin') sub = ['osx-bin', 'hermesc'];
115
+ else sub = ['linux64-bin', 'hermesc'];
116
+ const p = path.join(base, ...sub);
117
+ return fs.existsSync(p) ? p : null;
118
+ },
119
+ /**
120
+ * 文件名安全化:去除文件系统非法字符(/ \ : * ? " < > |)
121
+ * 空结果回退 'app'
122
+ */
123
+ sanitizeFileName = s => {
124
+ const cleaned = String(s).replace(/[\\/:*?"<>|]/g, '_').trim();
125
+ return cleaned || 'app';
126
+ };
127
+
128
+ export {
129
+ ensureDir, removeDir, isExcluded, copyDir, readJson, writeJson, writeIfChanged, copyFileIfChanged,
130
+ run, runQuiet, runSilent, findAdb, findHermesc, sanitizeFileName
131
+ };
@@ -0,0 +1,5 @@
1
+ export default {
2
+ arrowParens: 'avoid',
3
+ singleQuote: true,
4
+ trailingComma: 'all',
5
+ };