@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.
- package/Android /346/236/204/345/273/272/346/214/207/345/215/227.md" +315 -0
- package/LICENSE +29 -0
- package/README.md +448 -0
- package/bin/cli.js +49 -0
- package/cli-assets/server.js +37 -0
- package/mobileAppConfig.js +146 -0
- package/package.json +88 -0
- package/scripts/copy-files.js +37 -0
- package/src/commands/build.js +109 -0
- package/src/commands/clean.js +25 -0
- package/src/commands/test.js +108 -0
- package/src/config.js +112 -0
- package/src/copy.js +235 -0
- package/src/fingerprint.js +102 -0
- package/src/generate.js +102 -0
- package/src/install.js +144 -0
- package/src/patcher.js +374 -0
- package/src/platform-ios.js +142 -0
- package/src/utils.js +131 -0
- package/template/.prettierrc.js +5 -0
- package/template/.watchmanconfig +22 -0
- package/template/@types/nodejs-mobile-react-native/index.d.ts +9 -0
- package/template/App.tsx +108 -0
- package/template/Gemfile +17 -0
- package/template/android/app/build.gradle +127 -0
- package/template/android/app/debug.keystore +0 -0
- package/template/android/app/proguard-rules.pro +10 -0
- package/template/android/app/src/main/AndroidManifest.xml +27 -0
- package/template/android/app/src/main/java/com/myapp/MainActivity.kt +22 -0
- package/template/android/app/src/main/java/com/myapp/MainApplication.kt +27 -0
- package/template/android/app/src/main/res/drawable/rn_edit_text_material.xml +37 -0
- package/template/android/app/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
- package/template/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png +0 -0
- package/template/android/app/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
- package/template/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png +0 -0
- package/template/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
- package/template/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png +0 -0
- package/template/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
- package/template/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png +0 -0
- package/template/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
- package/template/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png +0 -0
- package/template/android/app/src/main/res/raw/keep.xml +1 -0
- package/template/android/app/src/main/res/values/strings.xml +3 -0
- package/template/android/app/src/main/res/values/styles.xml +9 -0
- package/template/android/build.gradle +21 -0
- package/template/android/gradle/wrapper/gradle-wrapper.jar +0 -0
- package/template/android/gradle/wrapper/gradle-wrapper.properties +7 -0
- package/template/android/gradle.properties +49 -0
- package/template/android/gradlew +248 -0
- package/template/android/gradlew.bat +98 -0
- package/template/android/settings.gradle +6 -0
- package/template/app.json +4 -0
- package/template/babel.config.js +3 -0
- package/template/eslint.config.js +6 -0
- package/template/index.js +12 -0
- package/template/ios/.xcode.env +11 -0
- package/template/ios/MyApp/AppDelegate.swift +55 -0
- package/template/ios/MyApp/Images.xcassets/AppIcon.appiconset/Contents.json +53 -0
- package/template/ios/MyApp/Images.xcassets/Contents.json +6 -0
- package/template/ios/MyApp/Info.plist +74 -0
- package/template/ios/MyApp/LaunchScreen.storyboard +47 -0
- package/template/ios/MyApp/PrivacyInfo.xcprivacy +37 -0
- package/template/ios/MyApp.xcodeproj/project.pbxproj +475 -0
- package/template/ios/MyApp.xcodeproj/xcshareddata/xcschemes/MyApp.xcscheme +88 -0
- package/template/ios/Podfile +34 -0
- package/template/metro.config.js +18 -0
- package/template/mobileApp.runtime.d.ts +16 -0
- package/template/package.json +18 -0
- package//350/207/252/345/256/232/344/271/211libnode/346/214/207/345/215/227.md +447 -0
package/src/copy.js
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import { ensureDir, removeDir, readJson, isExcluded, copyFileIfChanged, writeIfChanged } from './utils.js';
|
|
4
|
+
import { resolveSdkPath } from './config.js';
|
|
5
|
+
|
|
6
|
+
const BUILD_DIR_NAME = 'node-mobile-app-build',
|
|
7
|
+
TEMPLATE_EXCLUDE = [
|
|
8
|
+
'build/', '.gradle/', '.cxx/', '.kotlin/',
|
|
9
|
+
'app/build/', 'app/.cxx/',
|
|
10
|
+
'Pods/', 'local.properties', '*.bak', 'gradle.properties.bak',
|
|
11
|
+
],
|
|
12
|
+
/**
|
|
13
|
+
* 内部:递归覆盖式复制模板,跳过 keepSet 命中的文件
|
|
14
|
+
*/
|
|
15
|
+
copyDirOverwrite = (src, dst, exclude, buildDir, keepSet) => {
|
|
16
|
+
ensureDir(dst);
|
|
17
|
+
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
18
|
+
if (isExcluded(entry.name, exclude)) continue;
|
|
19
|
+
const s = path.join(src, entry.name), d = path.join(dst, entry.name);
|
|
20
|
+
if (entry.isDirectory()) copyDirOverwrite(s, d, exclude, buildDir, keepSet);
|
|
21
|
+
else if (entry.isFile()) {
|
|
22
|
+
const rel = path.relative(buildDir, d).replace(/\\/g, '/');
|
|
23
|
+
if (keepSet.has(rel)) continue;
|
|
24
|
+
copyFileIfChanged(s, d);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
/**
|
|
29
|
+
* 内部:增量复制用户项目到 nodejs-project
|
|
30
|
+
* 保留 node_modules 等由其他步骤生成的产物
|
|
31
|
+
*/
|
|
32
|
+
copyDirIncremental = (src, dst, exclude) => {
|
|
33
|
+
ensureDir(dst);
|
|
34
|
+
|
|
35
|
+
const dstKeep = new Set(['node_modules', '.deps-hash', 'main.js', 'package.json', 'package-lock.json']),
|
|
36
|
+
srcNames = new Set(fs.readdirSync(src));
|
|
37
|
+
// 1. 删除 dst 里不该存在的条目
|
|
38
|
+
for (const name of fs.readdirSync(dst)) {
|
|
39
|
+
if (dstKeep.has(name)) continue;
|
|
40
|
+
if (!srcNames.has(name) || isExcluded(name, exclude)) removeDir(path.join(dst, name));
|
|
41
|
+
}
|
|
42
|
+
// 2. 增量复制
|
|
43
|
+
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
44
|
+
if (isExcluded(entry.name, exclude)) continue;
|
|
45
|
+
if (dstKeep.has(entry.name)) continue;
|
|
46
|
+
const s = path.join(src, entry.name), d = path.join(dst, entry.name);
|
|
47
|
+
if (entry.isDirectory()) copyDirIncremental(s, d, exclude);
|
|
48
|
+
else if (entry.isFile()) copyFileIfChanged(s, d);
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
/**
|
|
52
|
+
* 获取 build 目录路径:<userProject>/node-mobile-app-build
|
|
53
|
+
*/
|
|
54
|
+
getBuildDir = userProjectDir => path.join(userProjectDir, BUILD_DIR_NAME),
|
|
55
|
+
/**
|
|
56
|
+
* 把 CLI 包里的 template/ 逐文件覆盖到 build 目录
|
|
57
|
+
* - 只覆盖模板里存在的文件,不删除 buildDir 里已有的其他内容(保留 Gradle 增量缓存)
|
|
58
|
+
* - 模板文件被改写时自动同步;新增文件自动添加
|
|
59
|
+
* - 若模板里删除了文件,旧副本残留由 CLI 版本指纹触发全量重建处理
|
|
60
|
+
*/
|
|
61
|
+
copyTemplate = (pkgRoot, buildDir, config) => {
|
|
62
|
+
ensureDir(buildDir);
|
|
63
|
+
|
|
64
|
+
const templateDir = path.join(pkgRoot, 'template');
|
|
65
|
+
if (!fs.existsSync(templateDir)) throw new Error('CLI 包缺少 template/ 目录: ' + templateDir);
|
|
66
|
+
|
|
67
|
+
const keepSet = new Set((config && config.keepFiles) || []);
|
|
68
|
+
copyDirOverwrite(templateDir, buildDir, TEMPLATE_EXCLUDE, buildDir, keepSet);
|
|
69
|
+
console.log(' ✓ 模板已更新到', BUILD_DIR_NAME + '/');
|
|
70
|
+
if (keepSet.size > 0) console.log(' 保留文件:', Array.from(keepSet).join(', '));
|
|
71
|
+
},
|
|
72
|
+
/**
|
|
73
|
+
* 复制用户 Node 项目到 <buildDir>/nodejs-assets/nodejs-project
|
|
74
|
+
* 复制后重写 package.json:只保留 dependencies,剔除 devDependencies;用 config 的 allowScripts
|
|
75
|
+
*/
|
|
76
|
+
copyUserProject = (userProjectDir, buildDir, config) => {
|
|
77
|
+
const targetDir = path.join(buildDir, 'nodejs-assets', 'nodejs-project');
|
|
78
|
+
ensureDir(targetDir);
|
|
79
|
+
|
|
80
|
+
const exclude = [
|
|
81
|
+
...config.excludeFiles, BUILD_DIR_NAME + '/',
|
|
82
|
+
'mobileAppConfig.js', 'mobileAppConfig.mjs', 'mobileAppConfig.cjs'
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
// 增量复制:内容变化的文件才覆盖,保留 node_modules 和其他未变文件
|
|
86
|
+
copyDirIncremental(userProjectDir, targetDir, exclude);
|
|
87
|
+
// 黑名单式过滤:只删 devDependencies 和 allowScripts,其余字段原样保留
|
|
88
|
+
const userPkgPath = path.join(userProjectDir, 'package.json');
|
|
89
|
+
if (!fs.existsSync(userPkgPath)) throw new Error('用户项目缺少 package.json');
|
|
90
|
+
const pkg = readJson(userPkgPath), out = { ...pkg };
|
|
91
|
+
delete out.devDependencies;
|
|
92
|
+
out.allowScripts = (config.allowScripts && Object.keys(config.allowScripts).length) ? config.allowScripts : { node: true };
|
|
93
|
+
writeIfChanged(path.join(targetDir, 'package.json'), JSON.stringify(out, null, 2) + '\n');
|
|
94
|
+
|
|
95
|
+
console.log(' ✓ 已同步用户项目到 nodejs-assets/nodejs-project');
|
|
96
|
+
console.log(' 模块类型:', out.type === 'module' ? 'ESM' : 'CJS');
|
|
97
|
+
console.log(' 生产依赖:', Object.keys(out.dependencies || {}).length, '个');
|
|
98
|
+
|
|
99
|
+
return { pkg: out, moduleType: out.type === 'module' ? 'module' : 'commonjs', targetDir };
|
|
100
|
+
},
|
|
101
|
+
/**
|
|
102
|
+
* 生成 <buildDir>/android/local.properties(Gradle 需要 sdk.dir)
|
|
103
|
+
*/
|
|
104
|
+
writeLocalProperties = (buildDir, config) => {
|
|
105
|
+
const sdkPath = resolveSdkPath(config);
|
|
106
|
+
if (!sdkPath) return console.warn(' ⚠️ 未找到 Android SDK,请在 mobileAppConfig.js 里配置 android.sdkPath');
|
|
107
|
+
|
|
108
|
+
const localProps = path.join(buildDir, 'android', 'local.properties'),
|
|
109
|
+
escaped = sdkPath.replace(/\\/g, '\\\\').replace(/:/g, '\\:');
|
|
110
|
+
fs.writeFileSync(localProps, `sdk.dir=${escaped}\n`, 'utf-8');
|
|
111
|
+
console.log(' ✓ 写入 sdk.dir:', sdkPath);
|
|
112
|
+
},
|
|
113
|
+
/**
|
|
114
|
+
* 确保用户项目根存在 serverPath 指向的入口文件
|
|
115
|
+
* 不存在时从 CLI 内置 cli-assets/server.js 复制一份
|
|
116
|
+
*/
|
|
117
|
+
ensureUserEntry = (userProjectDir, config, pkgRoot) => {
|
|
118
|
+
const entryPath = path.join(userProjectDir, config.serverPath);
|
|
119
|
+
if (fs.existsSync(entryPath)) return { created: false, path: entryPath };
|
|
120
|
+
|
|
121
|
+
const fallback = path.join(pkgRoot, 'cli-assets', 'server.js');
|
|
122
|
+
if (!fs.existsSync(fallback)) throw new Error('入口文件不存在: ' + config.serverPath + '(CLI 内置模板也缺失)');
|
|
123
|
+
|
|
124
|
+
ensureDir(path.dirname(entryPath));
|
|
125
|
+
fs.copyFileSync(fallback, entryPath);
|
|
126
|
+
console.log(' ✓ 生成默认入口文件:', config.serverPath);
|
|
127
|
+
console.log(' (因为用户项目里没有这个文件,从 CLI 内置模板复制)');
|
|
128
|
+
|
|
129
|
+
return { created: true, path: entryPath };
|
|
130
|
+
},
|
|
131
|
+
/**
|
|
132
|
+
* 确保用户项目根存在 mobileAppConfig.js
|
|
133
|
+
* 不存在时从 CLI 包根复制一份(带注释的模板)
|
|
134
|
+
*/
|
|
135
|
+
ensureUserConfig = (userProjectDir, pkgRoot) => {
|
|
136
|
+
const configPath = path.join(userProjectDir, 'mobileAppConfig.js');
|
|
137
|
+
if (fs.existsSync(configPath)) return { created: false, path: configPath };
|
|
138
|
+
|
|
139
|
+
const fallback = path.join(pkgRoot, 'mobileAppConfig.js');
|
|
140
|
+
if (!fs.existsSync(fallback)) throw new Error('CLI 缺少 mobileAppConfig.js 模板');
|
|
141
|
+
|
|
142
|
+
fs.copyFileSync(fallback, configPath);
|
|
143
|
+
console.log(' ✓ 生成默认配置文件: mobileAppConfig.js');
|
|
144
|
+
console.log(' (因为用户项目里没有这个文件,从 CLI 内置模板复制)');
|
|
145
|
+
|
|
146
|
+
return { created: true, path: configPath };
|
|
147
|
+
},
|
|
148
|
+
/**
|
|
149
|
+
* 按 config.android.abiFilters 修改 buildDir 里的 app/build.gradle:
|
|
150
|
+
* - defaultConfig.ndk.abiFilters
|
|
151
|
+
* - gradle.properties 的 reactNativeArchitectures
|
|
152
|
+
* 插件 build.gradle 读的是 project(":app").android.defaultConfig.ndk.abiFilters,
|
|
153
|
+
* 所以必须改这里,改插件自身的 abiFilters 无效。
|
|
154
|
+
*/
|
|
155
|
+
patchAndroidAbi = (buildDir, config) => {
|
|
156
|
+
const file = path.join(buildDir, 'android', 'app', 'build.gradle');
|
|
157
|
+
if (!fs.existsSync(file)) return console.warn(' ⚠️ 未找到 app/build.gradle,跳过 ABI patch');
|
|
158
|
+
|
|
159
|
+
const abiList = (config.android.abiFilters || []).map((x) => `"${x}"`).join(', ');
|
|
160
|
+
let c = fs.readFileSync(file, 'utf-8');
|
|
161
|
+
const before = c;
|
|
162
|
+
|
|
163
|
+
// defaultConfig 里的 ndk { abiFilters ... }
|
|
164
|
+
c = c.replace(
|
|
165
|
+
/(defaultConfig\s*\{[\s\S]*?ndk\s*\{\s*abiFilters\s+)("[^"]*"(?:\s*,\s*"[^"]*")*)/,
|
|
166
|
+
`$1${abiList}`
|
|
167
|
+
);
|
|
168
|
+
// 兜底:没有 ndk {} 块时,插到 defaultConfig 里
|
|
169
|
+
if (!/ndk\s*\{\s*abiFilters/.test(c)) {
|
|
170
|
+
c = c.replace(
|
|
171
|
+
/(defaultConfig\s*\{)/,
|
|
172
|
+
`$1\n ndk {\n abiFilters ${abiList}\n }`
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
// 不 patch splits.abi.include:AGP 禁止它和 ndk.abiFilters 同时存在
|
|
176
|
+
if (c !== before) writeIfChanged(file, c), console.log(` ✓ 已设置 app ndk.abiFilters = [${abiList}]`);
|
|
177
|
+
|
|
178
|
+
// 同步 gradle.properties 的 reactNativeArchitectures
|
|
179
|
+
const propsFile = path.join(buildDir, 'android', 'gradle.properties');
|
|
180
|
+
if (fs.existsSync(propsFile)) {
|
|
181
|
+
let p = fs.readFileSync(propsFile, 'utf-8');
|
|
182
|
+
const abiPlain = (config.android.abiFilters || []).join(','), before2 = p;
|
|
183
|
+
p = p.replace(/^reactNativeArchitectures=.*/gm, `reactNativeArchitectures=${abiPlain}`);
|
|
184
|
+
if (p !== before2) writeIfChanged(propsFile, p), console.log(` ✓ 已设置 reactNativeArchitectures = ${abiPlain}`);
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
/**
|
|
188
|
+
* 把用户在 mobileAppConfig.js 里声明的 extraRnDependencies 注入 buildDir/package.json
|
|
189
|
+
* 版本号从用户根 node_modules 读实际装的版本
|
|
190
|
+
* 用户没装的会中断并提示安装命令
|
|
191
|
+
*/
|
|
192
|
+
patchExtraRnDependencies = (buildDir, userProjectDir, config) => {
|
|
193
|
+
const extras = config.extraRnDependencies || [];
|
|
194
|
+
if (extras.length === 0) return;
|
|
195
|
+
|
|
196
|
+
const buildPkgPath = path.join(buildDir, 'package.json');
|
|
197
|
+
if (!fs.existsSync(buildPkgPath)) return console.warn(' ⚠️ 未找到 buildDir/package.json,跳过 extraRnDependencies');
|
|
198
|
+
|
|
199
|
+
const buildPkg = readJson(buildPkgPath);
|
|
200
|
+
if (!buildPkg.dependencies) buildPkg.dependencies = {};
|
|
201
|
+
|
|
202
|
+
const missing = [];
|
|
203
|
+
let added = 0;
|
|
204
|
+
for (const name of extras) {
|
|
205
|
+
const pkgJson = path.join(userProjectDir, 'node_modules', name, 'package.json');
|
|
206
|
+
if (!fs.existsSync(pkgJson)) {
|
|
207
|
+
missing.push(name);
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
try {
|
|
211
|
+
const v = JSON.parse(fs.readFileSync(pkgJson, 'utf-8')).version;
|
|
212
|
+
if (buildPkg.dependencies[name] !== v) buildPkg.dependencies[name] = v, added++;
|
|
213
|
+
} catch { missing.push(name); }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (added > 0) {
|
|
217
|
+
writeIfChanged(buildPkgPath, JSON.stringify(buildPkg, null, 2) + '\n');
|
|
218
|
+
console.log(` ✓ 已注入 ${added} 个额外 RN 依赖到 buildDir/package.json`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (missing.length > 0) {
|
|
222
|
+
throw new Error(
|
|
223
|
+
'extraRnDependencies 里以下包未安装:\n' +
|
|
224
|
+
' ' + missing.join(', ') + '\n' +
|
|
225
|
+
' 请先在项目根执行:\n' +
|
|
226
|
+
' npm install ' + missing.join(' ') + '\n' +
|
|
227
|
+
' 然后重新运行 node-mobile-app test'
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
export {
|
|
233
|
+
getBuildDir, copyTemplate, copyUserProject, writeLocalProperties, ensureUserEntry, ensureUserConfig, patchAndroidAbi,
|
|
234
|
+
patchExtraRnDependencies
|
|
235
|
+
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import crypto from 'crypto';
|
|
4
|
+
import { ensureDir, removeDir } from './utils.js';
|
|
5
|
+
|
|
6
|
+
const FINGERPRINT_FILE = '.build-fingerprint.json',
|
|
7
|
+
hashUserDeps = userProjectDir => {
|
|
8
|
+
const h = crypto.createHash('sha256'), pkgPath = path.join(userProjectDir, 'package.json');
|
|
9
|
+
if (fs.existsSync(pkgPath)) {
|
|
10
|
+
try {
|
|
11
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
12
|
+
h.update(JSON.stringify(pkg.dependencies || {})), h.update('|'), h.update(pkg.type || '');
|
|
13
|
+
} catch { h.update('package.json-read-error'); }
|
|
14
|
+
}
|
|
15
|
+
else h.update('no-package');
|
|
16
|
+
return h.digest('hex');
|
|
17
|
+
},
|
|
18
|
+
writeFp = (file, fp) => fs.writeFileSync(file, JSON.stringify(fp, null, 2), 'utf-8'),
|
|
19
|
+
clearCommonCaches = (buildDir, userProjectDir) => {
|
|
20
|
+
removeDir(path.join(buildDir, 'android', 'build'));
|
|
21
|
+
removeDir(path.join(buildDir, 'android', 'app', 'build'));
|
|
22
|
+
removeDir(path.join(userProjectDir, 'node_modules', 'nodejs-mobile-react-native', 'android', 'build'));
|
|
23
|
+
},
|
|
24
|
+
computeFingerprint = (userProjectDir, config, pkgRoot) => {
|
|
25
|
+
const abiFilters = [...(config.android.abiFilters || [])].sort(), libnode = {},
|
|
26
|
+
pluginDir = path.join(userProjectDir, 'node_modules', 'nodejs-mobile-react-native'),
|
|
27
|
+
libnodeBin = path.join(pluginDir, 'android', 'libnode', 'bin');
|
|
28
|
+
|
|
29
|
+
if (fs.existsSync(libnodeBin)) {
|
|
30
|
+
for (const arch of fs.readdirSync(libnodeBin)) {
|
|
31
|
+
const so = path.join(libnodeBin, arch, 'libnode.so');
|
|
32
|
+
if (fs.existsSync(so)) {
|
|
33
|
+
const stat = fs.statSync(so);
|
|
34
|
+
libnode[arch] = { size: stat.size, mtime: Math.floor(stat.mtimeMs) };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let pluginVersion = null;
|
|
40
|
+
const pluginPkg = path.join(pluginDir, 'package.json');
|
|
41
|
+
if (fs.existsSync(pluginPkg)) try { pluginVersion = JSON.parse(fs.readFileSync(pluginPkg, 'utf-8')).version; } catch { };
|
|
42
|
+
|
|
43
|
+
let cliVersion = null;
|
|
44
|
+
const cliPkg = path.join(pkgRoot, 'package.json');
|
|
45
|
+
if (fs.existsSync(cliPkg)) try { cliVersion = JSON.parse(fs.readFileSync(cliPkg, 'utf-8')).version; } catch { };
|
|
46
|
+
|
|
47
|
+
const depsHash = hashUserDeps(userProjectDir), a = config.android || {},
|
|
48
|
+
appInfo = {
|
|
49
|
+
appName: config.appName || null,
|
|
50
|
+
appId: config.appId || null,
|
|
51
|
+
versionCode: config.versionCode || null,
|
|
52
|
+
versionName: config.versionName || null,
|
|
53
|
+
minSdkVersion: a.minSdkVersion || null,
|
|
54
|
+
targetSdkVersion: a.targetSdkVersion || null,
|
|
55
|
+
compileSdkVersion: a.compileSdkVersion || null,
|
|
56
|
+
buildToolsVersion: a.buildToolsVersion || null,
|
|
57
|
+
usesCleartextTraffic: a.usesCleartextTraffic !== false,
|
|
58
|
+
permissions: [...(a.permissions || [])].sort(),
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
return { abiFilters, libnode, pluginVersion, cliVersion, depsHash, appInfo };
|
|
62
|
+
},
|
|
63
|
+
checkAndResetCache = (buildDir, userProjectDir, config, pkgRoot) => {
|
|
64
|
+
ensureDir(buildDir);
|
|
65
|
+
|
|
66
|
+
const fpFile = path.join(buildDir, FINGERPRINT_FILE), newFp = computeFingerprint(userProjectDir, config, pkgRoot);
|
|
67
|
+
let oldFp = null;
|
|
68
|
+
if (fs.existsSync(fpFile)) try { oldFp = JSON.parse(fs.readFileSync(fpFile, 'utf-8')); } catch { };
|
|
69
|
+
if (!oldFp) return console.log(' ✓ 首次构建,无缓存可清'), writeFp(fpFile, newFp);
|
|
70
|
+
|
|
71
|
+
const abiChanged = JSON.stringify(oldFp.abiFilters) !== JSON.stringify(newFp.abiFilters),
|
|
72
|
+
libnodeChanged = JSON.stringify(oldFp.libnode) !== JSON.stringify(newFp.libnode),
|
|
73
|
+
pluginChanged = oldFp.pluginVersion !== newFp.pluginVersion,
|
|
74
|
+
cliChanged = oldFp.cliVersion !== newFp.cliVersion, depsChanged = oldFp.depsHash !== newFp.depsHash,
|
|
75
|
+
appInfoChanged = JSON.stringify(oldFp.appInfo) !== JSON.stringify(newFp.appInfo),
|
|
76
|
+
structural = abiChanged || libnodeChanged || pluginChanged || cliChanged;
|
|
77
|
+
|
|
78
|
+
if (structural) {
|
|
79
|
+
const reasons = [];
|
|
80
|
+
if (abiChanged) reasons.push('ABI 配置');
|
|
81
|
+
if (libnodeChanged) reasons.push('libnode.so');
|
|
82
|
+
if (pluginChanged) reasons.push('插件版本');
|
|
83
|
+
if (cliChanged) reasons.push('CLI 版本');
|
|
84
|
+
if (depsChanged) reasons.push('用户依赖');
|
|
85
|
+
if (appInfoChanged) reasons.push('应用信息');
|
|
86
|
+
console.log(' ⚠️ 检测到变化(' + reasons.join('、') + '),清理全部缓存');
|
|
87
|
+
clearCommonCaches(buildDir, userProjectDir);
|
|
88
|
+
removeDir(path.join(userProjectDir, 'node_modules', 'nodejs-mobile-react-native', 'android', '.cxx'));
|
|
89
|
+
}
|
|
90
|
+
else if (depsChanged || appInfoChanged) {
|
|
91
|
+
const reasons = [];
|
|
92
|
+
if (depsChanged) reasons.push('用户依赖');
|
|
93
|
+
if (appInfoChanged) reasons.push('应用信息');
|
|
94
|
+
console.log(' ⚠️ 检测到变化(' + reasons.join('、') + '),清理 nodejs-assets 缓存 + app/build');
|
|
95
|
+
clearCommonCaches(buildDir, userProjectDir);
|
|
96
|
+
}
|
|
97
|
+
else console.log(' ✓ 缓存有效,跳过清理');
|
|
98
|
+
|
|
99
|
+
writeFp(fpFile, newFp);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export { computeFingerprint, checkAndResetCache };
|
package/src/generate.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { writeIfChanged } from './utils.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 生成 main.js(桥接代码,用户不需要手写)
|
|
7
|
+
*
|
|
8
|
+
* 上报机制(通过 rn-bridge channel 发消息给 App.tsx):
|
|
9
|
+
* - 'Node started on <URL>' server.js 成功调用 listen
|
|
10
|
+
* - 'Node failed: <原因>' 加载异常或 5 秒内未捕获 listen
|
|
11
|
+
*/
|
|
12
|
+
const generateMainJs = (targetDir, config, moduleType) => {
|
|
13
|
+
const entry = config.serverPath.startsWith('./') ? config.serverPath : './' + config.serverPath,
|
|
14
|
+
entryLabel = config.serverPath, hb = config.advanced.heartbeatInterval,
|
|
15
|
+
body = `const rn_bridge = require('rn-bridge');
|
|
16
|
+
const net = require('net'), https = require('https'), USER_ENTRY = ${JSON.stringify(entry)},
|
|
17
|
+
ENTRY_LABEL = ${JSON.stringify(entryLabel)}, START_TIMEOUT_MS = 5000;
|
|
18
|
+
|
|
19
|
+
let resolved = false;
|
|
20
|
+
function emitUrl(url) {
|
|
21
|
+
if (resolved) return;
|
|
22
|
+
resolved = true;
|
|
23
|
+
console.log('[node-mobile-app] server URL: ' + url);
|
|
24
|
+
rn_bridge.channel.send('Node started on ' + url);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function emitFailure(reason, detail) {
|
|
28
|
+
if (resolved) return;
|
|
29
|
+
resolved = true;
|
|
30
|
+
const msg = detail ? (reason + ' -- ' + detail) : reason;
|
|
31
|
+
console.log('[node-mobile-app] failed: ' + msg);
|
|
32
|
+
rn_bridge.channel.send('Node failed: ' + msg);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
rn_bridge.channel.send('Node was initialized.');
|
|
36
|
+
process.on('uncaughtException', (err) => {
|
|
37
|
+
console.error('Uncaught Exception:', err);
|
|
38
|
+
emitFailure('未捕获异常', err && err.message);
|
|
39
|
+
});
|
|
40
|
+
process.on('unhandledRejection', (reason) => {
|
|
41
|
+
console.error('Unhandled Rejection:', reason);
|
|
42
|
+
emitFailure('未处理的 Promise rejection', String(reason));
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// 拦截 net.Server.prototype.listen,捕获协议和真实端口
|
|
46
|
+
const origListen = net.Server.prototype.listen;
|
|
47
|
+
net.Server.prototype.listen = function (...args) {
|
|
48
|
+
const server = this;
|
|
49
|
+
if (!resolved) {
|
|
50
|
+
server.once('listening', () => {
|
|
51
|
+
try {
|
|
52
|
+
const addr = server.address(), port = (addr && typeof addr === 'object') ? addr.port : addr,
|
|
53
|
+
protocol = (server instanceof https.Server) ? 'https' : 'http';
|
|
54
|
+
emitUrl(protocol + '://127.0.0.1:' + port);
|
|
55
|
+
} catch (e) { /* 等超时 */ }
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
return origListen.apply(this, args);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// 5 秒超时兜底
|
|
62
|
+
setTimeout(() => {
|
|
63
|
+
if (resolved) return;
|
|
64
|
+
emitFailure('Node 服务 5 秒内未启动', '请检查 ' + ENTRY_LABEL + ' 是否调用了 listen()');
|
|
65
|
+
}, START_TIMEOUT_MS);
|
|
66
|
+
|
|
67
|
+
// 加载用户入口
|
|
68
|
+
(async () => {
|
|
69
|
+
try {
|
|
70
|
+
await import(USER_ENTRY);
|
|
71
|
+
} catch (err) {
|
|
72
|
+
emitFailure('加载 ' + ENTRY_LABEL + ' 失败', err && err.message);
|
|
73
|
+
}
|
|
74
|
+
})();
|
|
75
|
+
|
|
76
|
+
${config.advanced.keepAlive === false ? '// keepAlive: false — 不发送心跳' : "setInterval(() => rn_bridge.channel.send('heartbeat'), " + hb + ");"}
|
|
77
|
+
|
|
78
|
+
rn_bridge.channel.on('message', (msg) => {
|
|
79
|
+
rn_bridge.channel.send('Echo: ' + msg);
|
|
80
|
+
});`,
|
|
81
|
+
header = moduleType === 'module'
|
|
82
|
+
? `import { createRequire } from 'module';\nconst require = createRequire(import.meta.url);\n\n` : '',
|
|
83
|
+
file = path.join(targetDir, 'main.js'), changed = writeIfChanged(file, header + body);
|
|
84
|
+
console.log(changed ? ' ✓ 已生成 main.js' : ' ✓ main.js 无变化');
|
|
85
|
+
},
|
|
86
|
+
generateRuntime = (buildDir, config) => {
|
|
87
|
+
const data = {
|
|
88
|
+
showLoading: config.webview.showLoading,
|
|
89
|
+
loadingText: config.webview.loadingText,
|
|
90
|
+
webview: {
|
|
91
|
+
enableJavaScript: config.webview.enableJavaScript,
|
|
92
|
+
enableDomStorage: config.webview.enableDomStorage,
|
|
93
|
+
allowFileAccess: config.webview.allowFileAccess,
|
|
94
|
+
mixedContentMode: config.webview.mixedContentMode,
|
|
95
|
+
backgroundColor: config.webview.backgroundColor,
|
|
96
|
+
},
|
|
97
|
+
}, content = `// 自动生成,请勿手改\nexport default ${JSON.stringify(data, null, 2)} as const;\n`,
|
|
98
|
+
changed = writeIfChanged(path.join(buildDir, 'mobileApp.runtime.ts'), content);
|
|
99
|
+
console.log(changed ? ' ✓ 已生成 mobileApp.runtime.ts' : ' ✓ mobileApp.runtime.ts 无变化');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export { generateMainJs, generateRuntime };
|
package/src/install.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import crypto from 'crypto';
|
|
4
|
+
import { run } from './utils.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 内部:依赖 hash(dependencies + type)
|
|
8
|
+
*/
|
|
9
|
+
const hashDeps = pkgPath => {
|
|
10
|
+
const h = crypto.createHash('sha256');
|
|
11
|
+
if (fs.existsSync(pkgPath)) {
|
|
12
|
+
try {
|
|
13
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
14
|
+
h.update(JSON.stringify(pkg.dependencies || {})), h.update('|'), h.update(pkg.type || '');
|
|
15
|
+
} catch { h.update('read-error') }
|
|
16
|
+
}
|
|
17
|
+
else h.update('no-package');
|
|
18
|
+
return h.digest('hex');
|
|
19
|
+
},
|
|
20
|
+
/**
|
|
21
|
+
* 内部:定位 nodejs-mobile-react-native 插件目录
|
|
22
|
+
*/
|
|
23
|
+
findPluginDir = userProjectDir => {
|
|
24
|
+
const candidates = [
|
|
25
|
+
path.join(userProjectDir, 'node_modules', 'nodejs-mobile-react-native'),
|
|
26
|
+
];
|
|
27
|
+
for (const c of candidates) if (fs.existsSync(path.join(c, 'android', 'build.gradle'))) return c;
|
|
28
|
+
return null;
|
|
29
|
+
},
|
|
30
|
+
/**
|
|
31
|
+
* 内部:iOS 的 WebView SSL 补丁
|
|
32
|
+
*/
|
|
33
|
+
patchWebViewSslIos = userProjectDir => {
|
|
34
|
+
const file = path.join(
|
|
35
|
+
userProjectDir, 'node_modules', 'react-native-webview', 'apple', 'RNCWebViewImpl.m'
|
|
36
|
+
);
|
|
37
|
+
if (!fs.existsSync(file)) return;
|
|
38
|
+
|
|
39
|
+
let c = fs.readFileSync(file, 'utf-8');
|
|
40
|
+
if (c.includes('LOCALHOST_SSL_BYPASS_IOS')) return console.log(' ✓ iOS WebView SSL 补丁已应用');
|
|
41
|
+
|
|
42
|
+
// 在 "NSString* host = nil;" 那一段之后插入 localhost 放行
|
|
43
|
+
const anchor = ' if ([[challenge protectionSpace] authenticationMethod] == NSURLAuthenticationMethodClientCertificate) {',
|
|
44
|
+
idx = c.indexOf(anchor);
|
|
45
|
+
if (idx === -1) return console.warn(' ⚠️ RNCWebViewImpl.m 里未找到锚点,跳过 iOS SSL 补丁');
|
|
46
|
+
|
|
47
|
+
const injected = [
|
|
48
|
+
' // LOCALHOST_SSL_BYPASS_IOS: 对 localhost/127.0.0.1 放行自签名证书(仅本地测试用)',
|
|
49
|
+
' if (host != nil && ([host isEqualToString:@"127.0.0.1"] || [host isEqualToString:@"localhost"])) {',
|
|
50
|
+
' NSURLCredential *cred = [NSURLCredential credentialForTrust:challenge.protectionSpace.serverTrust];',
|
|
51
|
+
' completionHandler(NSURLSessionAuthChallengeUseCredential, cred);',
|
|
52
|
+
' return;',
|
|
53
|
+
' }',
|
|
54
|
+
'',
|
|
55
|
+
].join('\n');
|
|
56
|
+
|
|
57
|
+
c = c.substring(0, idx) + injected + c.substring(idx);
|
|
58
|
+
fs.writeFileSync(file, c, 'utf-8');
|
|
59
|
+
console.log(' ✓ 已应用 iOS WebView SSL 补丁(允许 localhost 自签证书)');
|
|
60
|
+
},
|
|
61
|
+
/**
|
|
62
|
+
* 装用户项目的生产依赖到 <buildDir>/nodejs-assets/nodejs-project/
|
|
63
|
+
* 只装 dependencies,不装 devDependencies
|
|
64
|
+
* 依赖未变化且 node_modules 存在时跳过(用 .deps-hash 记录)
|
|
65
|
+
*/
|
|
66
|
+
installUserDeps = (targetDir, config) => {
|
|
67
|
+
const assetsDir = path.dirname(targetDir), bnmFile = path.join(assetsDir, 'BUILD_NATIVE_MODULES.txt'),
|
|
68
|
+
hashFile = path.join(targetDir, '.deps-hash'), buildNative = !!config.android.buildNativeModules;
|
|
69
|
+
fs.writeFileSync(bnmFile, buildNative ? '1' : '0');
|
|
70
|
+
|
|
71
|
+
// 计算当前依赖 hash
|
|
72
|
+
const pkgPath = path.join(targetDir, 'package.json'), currentHash = hashDeps(pkgPath),
|
|
73
|
+
nodeModulesDir = path.join(targetDir, 'node_modules'), hasModules = fs.existsSync(nodeModulesDir);
|
|
74
|
+
let oldHash = null;
|
|
75
|
+
if (fs.existsSync(hashFile)) try { oldHash = fs.readFileSync(hashFile, 'utf-8').trim(); } catch { };
|
|
76
|
+
if (hasModules && oldHash === currentHash) return console.log(' ✓ 依赖无变化,跳过安装');
|
|
77
|
+
|
|
78
|
+
const env = { ...process.env };
|
|
79
|
+
if (buildNative) env.NODEJS_MOBILE_BUILD_NATIVE_MODULES = '1';
|
|
80
|
+
|
|
81
|
+
console.log(' 安装用户生产依赖(nodejs-assets/nodejs-project)...');
|
|
82
|
+
run('npm', ['install', '--omit=dev', '--no-audit', '--no-fund'], { cwd: targetDir, env });
|
|
83
|
+
fs.writeFileSync(hashFile, currentHash, 'utf-8');
|
|
84
|
+
console.log(' ✓ 用户依赖安装完成');
|
|
85
|
+
},
|
|
86
|
+
/**
|
|
87
|
+
* 修改 nodejs-mobile-react-native 插件 build.gradle 的 abiFilters
|
|
88
|
+
*/
|
|
89
|
+
patchNodejsMobilePlugin = (userProjectDir, config) => {
|
|
90
|
+
const pluginDir = findPluginDir(userProjectDir);
|
|
91
|
+
if (!pluginDir) return console.warn(' ⚠️ 未找到 nodejs-mobile-react-native 插件,跳过 patch');
|
|
92
|
+
|
|
93
|
+
const file = path.join(pluginDir, 'android', 'build.gradle'),
|
|
94
|
+
abiList = config.android.abiFilters.map((x) => `"${x}"`).join(', ');
|
|
95
|
+
|
|
96
|
+
let c = fs.readFileSync(file, 'utf-8');
|
|
97
|
+
const before = c;
|
|
98
|
+
c = c.replace(/abiFilters\s*=\s*\[[^\]]*\]/, `abiFilters = [${abiList}]`);
|
|
99
|
+
|
|
100
|
+
if (c !== before) fs.writeFileSync(file, c, 'utf-8'), console.log(` ✓ 已设置插件 abiFilters = [${abiList}]`);
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 让 react-native-webview 对 localhost 自签名证书放行
|
|
105
|
+
* Android: patch RNCWebViewClient.java
|
|
106
|
+
* iOS: patch RNCWebViewImpl.m
|
|
107
|
+
* 仅在 config.webview.allowSelfSignedCert === true 时应用
|
|
108
|
+
* 每次 test/build 都检查(npm 重装 webview 后会覆盖补丁)
|
|
109
|
+
*/
|
|
110
|
+
patchWebViewSsl = (userProjectDir, config) => {
|
|
111
|
+
if (!config.webview || config.webview.allowSelfSignedCert !== true) return;
|
|
112
|
+
|
|
113
|
+
const file = path.join(
|
|
114
|
+
userProjectDir, 'node_modules', 'react-native-webview', 'android', 'src', 'main', 'java', 'com',
|
|
115
|
+
'reactnativecommunity', 'webview', 'RNCWebViewClient.java'
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
if (fs.existsSync(file)) {
|
|
119
|
+
let c = fs.readFileSync(file, 'utf-8');
|
|
120
|
+
if (c.includes('LOCALHOST_SSL_BYPASS')) console.log(' ✓ Android WebView SSL 补丁已应用');
|
|
121
|
+
else {
|
|
122
|
+
const old = 'handler.cancel();', methodIndex = c.indexOf('onReceivedSslError');
|
|
123
|
+
if (methodIndex !== -1) {
|
|
124
|
+
const cancelIndex = c.indexOf(old, methodIndex);
|
|
125
|
+
if (cancelIndex !== -1) {
|
|
126
|
+
const newCode = [
|
|
127
|
+
'// LOCALHOST_SSL_BYPASS: 对 localhost/127.0.0.1 放行自签名证书(仅本地测试用)',
|
|
128
|
+
' if (failingUrl != null && (failingUrl.startsWith("https://127.0.0.1") || failingUrl.startsWith("https://localhost"))) {',
|
|
129
|
+
' handler.proceed();',
|
|
130
|
+
' return;',
|
|
131
|
+
' }',
|
|
132
|
+
' handler.cancel();',
|
|
133
|
+
].join('\n');
|
|
134
|
+
c = c.substring(0, cancelIndex) + newCode + c.substring(cancelIndex + old.length);
|
|
135
|
+
fs.writeFileSync(file, c, 'utf-8');
|
|
136
|
+
console.log(' ✓ 已应用 Android WebView SSL 补丁(允许 localhost 自签证书)');
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
patchWebViewSslIos(userProjectDir); // iOS 分支
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
export { installUserDeps, patchNodejsMobilePlugin, patchWebViewSsl };
|