android2harmony 0.1.4 → 0.1.6
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/README.md +1 -407
- package/agents/self-tester.md +55 -376
- package/dist/index.js +238 -124
- package/dist/index.js.map +4 -4
- package/package.json +36 -32
- package/skills/a2h-resource-convert/SKILL.md +931 -0
- package/skills/a2h-resource-convert/references/code-vector-icon-rules.md +335 -0
- package/skills/{hmos-resources-convert → a2h-resource-convert}/references/conversion-rules.md +15 -2
- package/skills/{hmos-resources-convert → a2h-resource-convert}/references/dependency-analysis-rules.md +24 -2
- package/skills/a2h-resource-convert/references/lottie-conversion-rules.md +219 -0
- package/skills/{hmos-resources-convert → a2h-resource-convert}/references/resource-mapping-rules.md +27 -2
- package/skills/{hmos-resources-convert → a2h-resource-convert}/references/xml-drawable-to-svg-rules.md +118 -1
- package/skills/a2h-resource-convert/scripts/a2h_resource_convert.js +2186 -0
- package/skills/a2h-resource-convert/scripts/app_identity.js +741 -0
- package/skills/a2h-resource-convert/scripts/code_vector_icons.js +607 -0
- package/skills/a2h-resource-convert/scripts/package.json +3 -0
- package/skills/a2h-resource-convert/scripts/svg_fidelity_check.js +632 -0
- package/skills/a2h-ui-transfer/SKILL.md +431 -0
- package/skills/a2h-ui-transfer/references/conversion-procedure.md +547 -0
- package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/_/350/214/203/345/274/217/351/200/211/346/213/251/350/257/264/346/230/216.md +1 -1
- package/skills/a2h-ui-transfer/references/mvvm-v2/_/350/243/205/351/245/260/345/231/250/351/200/237/346/237/245.md +133 -0
- package/skills/{hmos-batch-ui-align/scripts/android_parse_fast.ts → a2h-ui-transfer/scripts/android_parse_fast.js} +532 -301
- package/skills/a2h-ui-transfer/scripts/arkts_static_check.js +1624 -0
- package/skills/a2h-ui-transfer/scripts/measure_pack.js +1005 -0
- package/skills/a2h-ui-transfer/scripts/package.json +3 -0
- package/skills/hmos-fix-build-errors/SKILL.md +1 -1
- package/skills/hmos-incremental-ui-align/README.md +15 -15
- package/skills/hmos-incremental-ui-align/SKILL.md +15 -15
- package/skills/hmos-incremental-ui-align/references/State_Model_Template.md +2 -2
- package/skills/hmos-incremental-ui-align/scripts/app_feature_verify.js +790 -0
- package/skills/hmos-incremental-ui-align/scripts/extract_checklist.js +285 -0
- package/skills/hmos-incremental-ui-align/scripts/navigation-capure.md +76 -76
- package/skills/hmos-incremental-ui-align/scripts/page_capture.js +756 -0
- package/skills/hmos-incremental-ui-align/scripts/page_capture_burst.js +155 -0
- package/skills/hmos-integration-test/README.md +341 -0
- package/skills/hmos-integration-test/SKILL.md +446 -0
- package/skills/hmos-integration-test/scripts/report-tool.mjs +646 -0
- package/skills/hmos-integration-test/scripts/resolve-metadata-tool.mjs +147 -0
- package/skills/hmos-integration-test/scripts/self-test-runner.mjs +1006 -0
- package/skills/hmos-integration-test/scripts/testcases-tool.mjs +189 -0
- package/skills/hmos-spec-generate/SKILL.md +26 -24
- package/skills/hmos-spec-generate/scripts/parse_requirements.ts +515 -0
- package/skills/hmos-spec-generate/template/REQ.txt +22 -0
- package/skills/hmos-spec-generate/template/REQ.xlsx +0 -0
- package/skills/hmos-batch-ui-align/SKILL.md +0 -141
- package/skills/hmos-batch-ui-align/references/conversion-procedure.md +0 -217
- package/skills/hmos-incremental-ui-align/scripts/app_feature_verify.ts +0 -999
- package/skills/hmos-incremental-ui-align/scripts/extract_checklist.ts +0 -343
- package/skills/hmos-incremental-ui-align/scripts/page_capture.ts +0 -977
- package/skills/hmos-incremental-ui-align/scripts/page_capture_burst.ts +0 -188
- package/skills/hmos-resources-convert/SKILL.md +0 -654
- package/skills/hmos-resources-convert/template/AppScope/app.json5 +0 -10
- package/skills/hmos-resources-convert/template/AppScope/resources/base/element/string.json +0 -8
- package/skills/hmos-resources-convert/template/AppScope/resources/base/media/background.png +0 -0
- package/skills/hmos-resources-convert/template/AppScope/resources/base/media/foreground.png +0 -0
- package/skills/hmos-resources-convert/template/AppScope/resources/base/media/layered_image.json +0 -7
- package/skills/hmos-resources-convert/template/build-profile.json5 +0 -42
- package/skills/hmos-resources-convert/template/code-linter.json5 +0 -32
- package/skills/hmos-resources-convert/template/entry/build-profile.json5 +0 -33
- package/skills/hmos-resources-convert/template/entry/hvigorfile.ts +0 -6
- package/skills/hmos-resources-convert/template/entry/obfuscation-rules.txt +0 -23
- package/skills/hmos-resources-convert/template/entry/oh-package.json5 +0 -10
- package/skills/hmos-resources-convert/template/entry/src/main/ets/entryability/EntryAbility.ets +0 -48
- package/skills/hmos-resources-convert/template/entry/src/main/ets/entrybackupability/EntryBackupAbility.ets +0 -16
- package/skills/hmos-resources-convert/template/entry/src/main/ets/pages/Index.ets +0 -23
- package/skills/hmos-resources-convert/template/entry/src/main/module.json5 +0 -55
- package/skills/hmos-resources-convert/template/entry/src/main/resources/base/element/color.json +0 -8
- package/skills/hmos-resources-convert/template/entry/src/main/resources/base/element/float.json +0 -8
- package/skills/hmos-resources-convert/template/entry/src/main/resources/base/element/string.json +0 -16
- package/skills/hmos-resources-convert/template/entry/src/main/resources/base/media/background.png +0 -0
- package/skills/hmos-resources-convert/template/entry/src/main/resources/base/media/foreground.png +0 -0
- package/skills/hmos-resources-convert/template/entry/src/main/resources/base/media/layered_image.json +0 -7
- package/skills/hmos-resources-convert/template/entry/src/main/resources/base/media/startIcon.png +0 -0
- package/skills/hmos-resources-convert/template/entry/src/main/resources/base/profile/backup_config.json +0 -3
- package/skills/hmos-resources-convert/template/entry/src/main/resources/base/profile/main_pages.json +0 -5
- package/skills/hmos-resources-convert/template/entry/src/main/resources/dark/element/color.json +0 -8
- package/skills/hmos-resources-convert/template/entry/src/mock/mock-config.json5 +0 -2
- package/skills/hmos-resources-convert/template/entry/src/ohosTest/ets/test/Ability.test.ets +0 -35
- package/skills/hmos-resources-convert/template/entry/src/ohosTest/ets/test/List.test.ets +0 -5
- package/skills/hmos-resources-convert/template/entry/src/ohosTest/module.json5 +0 -16
- package/skills/hmos-resources-convert/template/entry/src/test/List.test.ets +0 -5
- package/skills/hmos-resources-convert/template/entry/src/test/LocalUnit.test.ets +0 -33
- package/skills/hmos-resources-convert/template/hvigor/hvigor-config.json5 +0 -23
- package/skills/hmos-resources-convert/template/hvigorfile.ts +0 -6
- package/skills/hmos-resources-convert/template/oh-package-lock.json5 +0 -28
- package/skills/hmos-resources-convert/template/oh-package.json5 +0 -10
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mappings/android-to-harmonyOS-ui-atomic-component-mapping-reference.md +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mappings/android-to-harmonyOS-ui-interaction-mapping-reference.md +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mappings/android-to-harmonyOS-ui-layout-mapping-reference.md +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/@Link/350/243/205/351/245/260/345/231/250/357/274/232/347/210/266/345/255/220/345/217/214/345/220/221/345/220/214/346/255/245.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/@Observed/350/243/205/351/245/260/345/231/250/345/222/214@ObjectLink/350/243/205/351/245/260/345/231/250/357/274/232/345/265/214/345/245/227/347/261/273/345/257/271/350/261/241/345/261/236/346/200/247/345/217/230/345/214/226.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/@Prop/350/243/205/351/245/260/345/231/250/357/274/232/347/210/266/345/255/220/345/215/225/345/220/221/345/220/214/346/255/245.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/@Provide/350/243/205/351/245/260/345/231/250/345/222/214@Consume/350/243/205/351/245/260/345/231/250/357/274/232/344/270/216/345/220/216/344/273/243/347/273/204/344/273/266/345/217/214/345/220/221/345/220/214/346/255/245.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/@State/350/243/205/351/245/260/345/231/250/357/274/232/347/273/204/344/273/266/345/206/205/347/212/266/346/200/201.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/@Track/350/243/205/351/245/260/345/231/250/357/274/232class/345/257/271/350/261/241/345/261/236/346/200/247/347/272/247/346/233/264/346/226/260.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/@Watch/350/243/205/351/245/260/345/231/250/357/274/232/347/212/266/346/200/201/345/217/230/351/207/217/346/233/264/346/224/271/351/200/232/347/237/245.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/AppStorage/357/274/232/345/272/224/347/224/250/345/205/250/345/261/200/347/232/204UI/347/212/266/346/200/201/345/255/230/345/202/250.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/Environment/357/274/232/350/256/276/345/244/207/347/216/257/345/242/203/346/237/245/350/257/242.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/LocalStorage/357/274/232/351/241/265/351/235/242/347/272/247UI/347/212/266/346/200/201/345/255/230/345/202/250.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/MVVM/346/250/241/345/274/217/357/274/210V1/357/274/211.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/PersistentStorage/357/274/232/346/214/201/344/271/205/345/214/226/345/255/230/345/202/250UI/347/212/266/346/200/201.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm//347/256/241/347/220/206/345/272/224/347/224/250/346/213/245/346/234/211/347/232/204/347/212/266/346/200/201/346/246/202/350/277/260.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/!!/350/257/255/346/263/225/357/274/232/345/217/214/345/220/221/347/273/221/345/256/232.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/@Computed/350/243/205/351/245/260/345/231/250/357/274/232/350/256/241/347/256/227/345/261/236/346/200/247.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/@Event/350/243/205/351/245/260/345/231/250/357/274/232/350/247/204/350/214/203/347/273/204/344/273/266/350/276/223/345/207/272.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/@Local/350/243/205/351/245/260/345/231/250/357/274/232/347/273/204/344/273/266/345/206/205/351/203/250/347/212/266/346/200/201.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/@Monitor/350/243/205/351/245/260/345/231/250/357/274/232/347/212/266/346/200/201/345/217/230/351/207/217/344/277/256/346/224/271/345/274/202/346/255/245/347/233/221/345/220/254.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/@ObservedV2/350/243/205/351/245/260/345/231/250/345/222/214@Trace/350/243/205/351/245/260/345/231/250/357/274/232/347/261/273/345/261/236/346/200/247/345/217/230/345/214/226/350/247/202/346/265/213.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/@Once/350/243/205/351/245/260/345/231/250/357/274/232/345/210/235/345/247/213/345/214/226/345/220/214/346/255/245/344/270/200/346/254/241.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/@Param/350/243/205/351/245/260/345/231/250/357/274/232/347/273/204/344/273/266/345/244/226/351/203/250/350/276/223/345/205/245.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/@Provider/350/243/205/351/245/260/345/231/250/345/222/214@Consumer/350/243/205/351/245/260/345/231/250/357/274/232/350/267/250/347/273/204/344/273/266/345/261/202/347/272/247/345/217/214/345/220/221/345/220/214/346/255/245.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/@Type/350/243/205/351/245/260/345/231/250/357/274/232/346/240/207/350/256/260/347/261/273/345/261/236/346/200/247/347/232/204/347/261/273/345/236/213.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/AppStorageV2/357/274/232/345/272/224/347/224/250/345/205/250/345/261/200UI/347/212/266/346/200/201/345/255/230/345/202/250.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/MVVM/346/250/241/345/274/217/357/274/210V2/357/274/211.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/PersistenceV2/357/274/232/346/214/201/344/271/205/345/214/226/345/255/230/345/202/250UI/347/212/266/346/200/201.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2//347/212/266/346/200/201/347/256/241/347/220/206V1/345/220/221V2/350/277/201/347/247/273/344/270/216/346/267/267/347/224/250/346/214/207/345/257/274.md" +0 -0
|
@@ -0,0 +1,741 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* app_identity.js —— 应用标识(应用图标 + 应用名)迁移。
|
|
3
|
+
*
|
|
4
|
+
* 为什么单独一个脚本:a2h_resource_convert.js 把 mipmap 图标和 app_name 字符串
|
|
5
|
+
* 都转成了 HarmonyOS 资源,但它们全部落在 `<ui_module>/src/main/resources` 下,
|
|
6
|
+
* 而桌面图标与应用名由 `AppScope/app.json5` 决定,其 `$media:` / `$string:`
|
|
7
|
+
* 解析域是 `AppScope/resources`,不是 entry 模块。加上没有任何环节解析过
|
|
8
|
+
* AndroidManifest 的 `android:icon` / `android:label`(也就无从知道哪个 mipmap
|
|
9
|
+
* 才是启动图标),结果是素材都在、配置仍是 DevEco 模板默认值:默认图标 + "label"。
|
|
10
|
+
*
|
|
11
|
+
* 这个缺口过不了任何既有 gate:主转换的校验都是存在性/保真度检查,而模板里的
|
|
12
|
+
* `$media:layered_image` 指向模板自带的合法文件,引用检查照样全绿。所以本脚本
|
|
13
|
+
* 自带 `--check`,把「配置确实指向了 Android 侧那个图标/名字」变成可阻塞的判定。
|
|
14
|
+
*
|
|
15
|
+
* 无第三方依赖(本包 dependencies 恒为空)。json5 一律定点正则改写字符串值,
|
|
16
|
+
* 不做 parse + 重新序列化:模板注释和缩进要原样保留,diff 也才看得懂。
|
|
17
|
+
*
|
|
18
|
+
* 用法:
|
|
19
|
+
* node app_identity.js --manifest <decoded>/AndroidManifest.xml --res <decoded>/res \
|
|
20
|
+
* --harmony <harmony_project_dir> --ui-module <ui_module> \
|
|
21
|
+
* --json <work_dir>/app_identity.json [--dry-run] [--quiet]
|
|
22
|
+
* node app_identity.js --check --json <work_dir>/app_identity.json
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import * as fs from 'node:fs';
|
|
26
|
+
import * as path from 'node:path';
|
|
27
|
+
|
|
28
|
+
const TAG = '[app_identity]';
|
|
29
|
+
|
|
30
|
+
function fail(msg) {
|
|
31
|
+
console.error(`${TAG} ${msg}`);
|
|
32
|
+
process.exit(2);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ─────────────────────────────────────────────
|
|
36
|
+
// 常量表(与 a2h_resource_convert.js 保持一致)
|
|
37
|
+
// ─────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
/** Android 密度限定符 → HarmonyOS 密度限定符。见 conversion-rules.md「Density Qualifiers」。 */
|
|
40
|
+
const DENSITY = {
|
|
41
|
+
ldpi: 'sdpi', mdpi: 'mdpi', tvdpi: 'ldpi', hdpi: 'ldpi',
|
|
42
|
+
xhdpi: 'xldpi', xxhdpi: 'xxldpi', xxxhdpi: 'xxxldpi',
|
|
43
|
+
nodpi: null, anydpi: null,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** 取「最像原图」的一份拷进 AppScope:AppScope 侧只需要一个源文件。 */
|
|
47
|
+
const DENSITY_RANK = ['xxxhdpi', 'xxhdpi', 'xhdpi', 'hdpi', 'tvdpi', 'mdpi', 'ldpi', 'nodpi', 'anydpi', ''];
|
|
48
|
+
|
|
49
|
+
const STRIP_RE = /^(v\d+|ldrtl|ldltr|round|notround|highdr|lowdr|long|notlong)$/;
|
|
50
|
+
const SKIP_RE = /^(sw\d+dp|w\d+dp|h\d+dp|small|normal|large|xlarge)$/;
|
|
51
|
+
const ORIENTATION = { land: 'horizontal', port: 'vertical' };
|
|
52
|
+
const COLORMODE = { night: 'dark', notnight: 'light' };
|
|
53
|
+
const DEVICE = { watch: 'wearable', television: 'tv', car: 'car', desk: 'phone', appliance: 'phone' };
|
|
54
|
+
|
|
55
|
+
const IMAGE_EXT = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg']);
|
|
56
|
+
|
|
57
|
+
// ─────────────────────────────────────────────
|
|
58
|
+
// 极简 XML 解析(只覆盖 manifest / values / adaptive-icon 需要的语法)
|
|
59
|
+
// ─────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
|
|
62
|
+
|
|
63
|
+
function decodeEntities(s) {
|
|
64
|
+
return s.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (m, body) => {
|
|
65
|
+
if (body[0] === '#') {
|
|
66
|
+
const code = body[1] === 'x' || body[1] === 'X'
|
|
67
|
+
? parseInt(body.slice(2), 16)
|
|
68
|
+
: parseInt(body.slice(1), 10);
|
|
69
|
+
return Number.isFinite(code) ? String.fromCodePoint(code) : m;
|
|
70
|
+
}
|
|
71
|
+
return ENTITIES[body] ?? m;
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function parseXml(src) {
|
|
76
|
+
let i = 0;
|
|
77
|
+
const n = src.length;
|
|
78
|
+
|
|
79
|
+
function skipUntil(marker) {
|
|
80
|
+
const at = src.indexOf(marker, i);
|
|
81
|
+
i = at < 0 ? n : at + marker.length;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function parseAttrs(end) {
|
|
85
|
+
const attrs = {};
|
|
86
|
+
const re = /([^\s=/>]+)\s*=\s*("([^"]*)"|'([^']*)')/g;
|
|
87
|
+
const slice = src.slice(i, end);
|
|
88
|
+
let m;
|
|
89
|
+
while ((m = re.exec(slice)) !== null) attrs[m[1]] = decodeEntities(m[3] ?? m[4] ?? '');
|
|
90
|
+
return attrs;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function parseNode() {
|
|
94
|
+
while (i < n) {
|
|
95
|
+
const lt = src.indexOf('<', i);
|
|
96
|
+
if (lt < 0) return null;
|
|
97
|
+
i = lt;
|
|
98
|
+
if (src.startsWith('<!--', i)) { skipUntil('-->'); continue; }
|
|
99
|
+
if (src.startsWith('<?', i)) { skipUntil('?>'); continue; }
|
|
100
|
+
if (src.startsWith('<!DOCTYPE', i) || src.startsWith('<!', i)) { skipUntil('>'); continue; }
|
|
101
|
+
if (src.startsWith('</', i)) return null;
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
if (i >= n) return null;
|
|
105
|
+
|
|
106
|
+
const gt = src.indexOf('>', i);
|
|
107
|
+
if (gt < 0) return null;
|
|
108
|
+
const selfClosing = src[gt - 1] === '/';
|
|
109
|
+
const head = src.slice(i + 1, selfClosing ? gt - 1 : gt);
|
|
110
|
+
const name = head.split(/[\s/]/, 1)[0];
|
|
111
|
+
const attrs = parseAttrs(gt);
|
|
112
|
+
i = gt + 1;
|
|
113
|
+
|
|
114
|
+
const node = { name, attrs, children: [], inner: '' };
|
|
115
|
+
if (selfClosing) return node;
|
|
116
|
+
|
|
117
|
+
const innerStart = i;
|
|
118
|
+
while (i < n) {
|
|
119
|
+
const lt = src.indexOf('<', i);
|
|
120
|
+
if (lt < 0) { i = n; break; }
|
|
121
|
+
if (src.startsWith('<![CDATA[', lt)) { i = lt; skipUntil(']]>'); continue; }
|
|
122
|
+
if (src.startsWith('<!--', lt)) { i = lt; skipUntil('-->'); continue; }
|
|
123
|
+
if (src.startsWith(`</${name}`, lt)) {
|
|
124
|
+
node.inner = src.slice(innerStart, lt);
|
|
125
|
+
i = src.indexOf('>', lt);
|
|
126
|
+
i = i < 0 ? n : i + 1;
|
|
127
|
+
return node;
|
|
128
|
+
}
|
|
129
|
+
i = lt;
|
|
130
|
+
const child = parseNode();
|
|
131
|
+
if (child) node.children.push(child);
|
|
132
|
+
else i = lt + 1;
|
|
133
|
+
}
|
|
134
|
+
node.inner = src.slice(innerStart);
|
|
135
|
+
return node;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return parseNode() ?? { name: '', attrs: {}, children: [], inner: '' };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** 属性按 localName 取值(忽略 `android:` 之类的前缀)。 */
|
|
142
|
+
function attr(node, local) {
|
|
143
|
+
for (const [k, v] of Object.entries(node.attrs)) {
|
|
144
|
+
if (k === local || k.endsWith(`:${local}`)) return v;
|
|
145
|
+
}
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function findChild(node, name) {
|
|
150
|
+
return node.children.find((c) => c.name === name);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Android 字符串字面量 → HarmonyOS 字符串值。剥外层引号那步见 SKILL.md Step 6.11。 */
|
|
154
|
+
function normalizeString(inner) {
|
|
155
|
+
let s = inner;
|
|
156
|
+
const cdata = [...s.matchAll(/<!\[CDATA\[([\s\S]*?)\]\]>/g)];
|
|
157
|
+
if (cdata.length > 0) s = cdata.map((m) => m[1]).join('');
|
|
158
|
+
s = decodeEntities(s);
|
|
159
|
+
s = s.replace(/\\(['"@?\\])/g, '$1').replace(/\\n/g, '\n').replace(/\\t/g, '\t');
|
|
160
|
+
if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) s = s.slice(1, -1);
|
|
161
|
+
return s;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ─────────────────────────────────────────────
|
|
165
|
+
// 限定符
|
|
166
|
+
// ─────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* `values-zh-rCN` / `mipmap-xxhdpi` → HarmonyOS 限定符目录名 + 原始密度段。
|
|
170
|
+
* 与 a2h_resource_convert.js 的 planQualifiers 同规则,额外回传 density
|
|
171
|
+
* 原名,供 DENSITY_RANK 择优。
|
|
172
|
+
*/
|
|
173
|
+
function planQualifiers(dirName) {
|
|
174
|
+
const segs = dirName.split('-');
|
|
175
|
+
const type = segs[0];
|
|
176
|
+
let language = '';
|
|
177
|
+
let script = '';
|
|
178
|
+
let region = '';
|
|
179
|
+
let orientation = '';
|
|
180
|
+
let device = '';
|
|
181
|
+
let colormode = '';
|
|
182
|
+
let density = '';
|
|
183
|
+
let rawDensity = '';
|
|
184
|
+
|
|
185
|
+
for (const q of segs.slice(1)) {
|
|
186
|
+
if (SKIP_RE.test(q)) return { type, dir: '', skip: true, rawDensity };
|
|
187
|
+
if (STRIP_RE.test(q)) continue;
|
|
188
|
+
if (q in DENSITY) { rawDensity = q; if (DENSITY[q]) density = DENSITY[q]; continue; }
|
|
189
|
+
if (q in ORIENTATION) { orientation = ORIENTATION[q]; continue; }
|
|
190
|
+
if (q in COLORMODE) { colormode = COLORMODE[q]; continue; }
|
|
191
|
+
if (q in DEVICE) { device = DEVICE[q]; continue; }
|
|
192
|
+
if (/^b\+/.test(q)) {
|
|
193
|
+
const parts = q.slice(2).split('+');
|
|
194
|
+
language = parts[0] ?? '';
|
|
195
|
+
if (parts[1] && parts[1].length === 4) script = parts[1];
|
|
196
|
+
if (parts[2]) region = parts[2];
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (/^r(?:[A-Z]{2}|\d{3})$/.test(q) && language) { region = q.slice(1); continue; }
|
|
200
|
+
if (/^[a-z]{2,3}$/.test(q) && !language) { language = q; continue; }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const locale = [language, script, region].filter(Boolean).join('_');
|
|
204
|
+
const dir = [locale, orientation, device, colormode, density].filter(Boolean).join('-') || 'base';
|
|
205
|
+
return { type, dir, skip: false, rawDensity };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ─────────────────────────────────────────────
|
|
209
|
+
// AndroidManifest:应用图标与应用名
|
|
210
|
+
// ─────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* 取 `<application>` 的 android:icon / android:roundIcon / android:label。
|
|
214
|
+
*
|
|
215
|
+
* 解码产物里这些属性可能已经被 aapt 解析成字面量(SKILL.md Step 4 说明解码时
|
|
216
|
+
* id 引用已解析),也可能仍是 `@mipmap/ic_launcher` / `@string/app_name`,
|
|
217
|
+
* 两种形态都要接:只按引用形态处理会在一半的工程上什么都取不到。
|
|
218
|
+
*/
|
|
219
|
+
function readManifestIdentity(manifestPath) {
|
|
220
|
+
if (!fs.existsSync(manifestPath)) fail(`--manifest 路径不存在: ${manifestPath}`);
|
|
221
|
+
const root = parseXml(fs.readFileSync(manifestPath, 'utf8'));
|
|
222
|
+
const app = root.name === 'application' ? root : findChild(root, 'application');
|
|
223
|
+
if (!app) fail(`${manifestPath} 中找不到 <application> 元素`);
|
|
224
|
+
return {
|
|
225
|
+
icon: attr(app, 'icon') ?? '',
|
|
226
|
+
roundIcon: attr(app, 'roundIcon') ?? '',
|
|
227
|
+
label: attr(app, 'label') ?? '',
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** `@mipmap/ic_launcher` / `@drawable/x` → `ic_launcher`;非引用返回 null。 */
|
|
232
|
+
function drawableRefName(raw) {
|
|
233
|
+
const m = /^@(?:[a-zA-Z0-9._]+:)?(?:drawable|mipmap)\/(.+)$/.exec(raw || '');
|
|
234
|
+
return m ? m[1] : null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** `@string/app_name` → `app_name`;非引用返回 null。 */
|
|
238
|
+
function stringRefName(raw) {
|
|
239
|
+
const m = /^@(?:[a-zA-Z0-9._]+:)?string\/(.+)$/.exec(raw || '');
|
|
240
|
+
return m ? m[1] : null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ─────────────────────────────────────────────
|
|
244
|
+
// 解码 res/ 索引
|
|
245
|
+
// ─────────────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* 扫描 res/ 下所有 drawable* / mipmap* 目录,建立 资源名 → 候选文件列表。
|
|
249
|
+
* 候选带上原始密度段,便于按 DENSITY_RANK 择优。
|
|
250
|
+
*/
|
|
251
|
+
function indexDrawables(resDir) {
|
|
252
|
+
const index = new Map();
|
|
253
|
+
for (const dirName of fs.readdirSync(resDir)) {
|
|
254
|
+
const abs = path.join(resDir, dirName);
|
|
255
|
+
if (!fs.statSync(abs).isDirectory()) continue;
|
|
256
|
+
const q = planQualifiers(dirName);
|
|
257
|
+
if (q.skip) continue;
|
|
258
|
+
if (q.type !== 'drawable' && q.type !== 'mipmap') continue;
|
|
259
|
+
for (const file of fs.readdirSync(abs)) {
|
|
260
|
+
const ext = path.extname(file).toLowerCase();
|
|
261
|
+
const name = path.basename(file, path.extname(file)).replace(/\.9$/, '');
|
|
262
|
+
if (!index.has(name)) index.set(name, []);
|
|
263
|
+
index.get(name).push({ abs: path.join(abs, file), dirName, ext, rawDensity: q.rawDensity });
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return index;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function bestByDensity(candidates) {
|
|
270
|
+
const scored = candidates.map((c) => ({ c, rank: DENSITY_RANK.indexOf(c.rawDensity) }));
|
|
271
|
+
scored.sort((a, b) => (a.rank < 0 ? 99 : a.rank) - (b.rank < 0 ? 99 : b.rank));
|
|
272
|
+
return scored.length > 0 ? scored[0].c : null;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ─────────────────────────────────────────────
|
|
276
|
+
// 应用名:所有语言变体
|
|
277
|
+
// ─────────────────────────────────────────────
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* 在 `res/values…/strings.xml` 里找 `<string name="<key>">`,返回
|
|
281
|
+
* HarmonyOS 限定符目录 → 值。base 之外的语言变体一并带出:
|
|
282
|
+
* 应用名是最该本地化的一条字符串,只写 base 等于把多语言丢掉。
|
|
283
|
+
*/
|
|
284
|
+
function collectLabelStrings(resDir, key) {
|
|
285
|
+
const out = new Map();
|
|
286
|
+
for (const dirName of fs.readdirSync(resDir)) {
|
|
287
|
+
const abs = path.join(resDir, dirName);
|
|
288
|
+
if (!fs.statSync(abs).isDirectory()) continue;
|
|
289
|
+
const q = planQualifiers(dirName);
|
|
290
|
+
if (q.skip || q.type !== 'values') continue;
|
|
291
|
+
for (const file of fs.readdirSync(abs)) {
|
|
292
|
+
if (path.extname(file).toLowerCase() !== '.xml') continue;
|
|
293
|
+
const root = parseXml(fs.readFileSync(path.join(abs, file), 'utf8'));
|
|
294
|
+
for (const el of root.children) {
|
|
295
|
+
if (el.name !== 'string' || attr(el, 'name') !== key) continue;
|
|
296
|
+
// 同一限定符下先出现的优先:与主转换的同名冲突处理方向一致
|
|
297
|
+
if (!out.has(q.dir)) out.set(q.dir, normalizeString(el.inner));
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return out;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ─────────────────────────────────────────────
|
|
305
|
+
// json5 定点改写
|
|
306
|
+
// ─────────────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* 把 `<key>: "<旧值>"` 的字符串值换成 newValue,只在 [from, to) 区间内找。
|
|
310
|
+
*
|
|
311
|
+
* 刻意不 parse json5 再序列化:DevEco 模板里的注释会全部丢掉,且整文件重排的
|
|
312
|
+
* diff 无法审阅。键名可能带引号也可能不带,两种都要匹配;`\b` 保证 `icon`
|
|
313
|
+
* 不会误命中 `startWindowIcon`。返回改写后的文本与旧值,改不到则 changed=false。
|
|
314
|
+
*/
|
|
315
|
+
function patchJson5Value(text, key, newValue, from = 0, to = text.length) {
|
|
316
|
+
const region = text.slice(from, to);
|
|
317
|
+
const re = new RegExp(`((?:"${key}"|'${key}'|\\b${key})\\s*:\\s*)(?:"([^"]*)"|'([^']*)')`);
|
|
318
|
+
const m = re.exec(region);
|
|
319
|
+
if (!m) return { text, changed: false, oldValue: null };
|
|
320
|
+
const oldValue = m[2] ?? m[3] ?? '';
|
|
321
|
+
if (oldValue === newValue) return { text, changed: false, oldValue, alreadySet: true };
|
|
322
|
+
const patchedRegion = region.slice(0, m.index)
|
|
323
|
+
+ m[1] + JSON.stringify(newValue)
|
|
324
|
+
+ region.slice(m.index + m[0].length);
|
|
325
|
+
return { text: text.slice(0, from) + patchedRegion + text.slice(to), changed: true, oldValue };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** 读 `<key>: "<值>"` 而不改写。--check 用。 */
|
|
329
|
+
function readJson5Value(text, key, from = 0, to = text.length) {
|
|
330
|
+
const re = new RegExp(`(?:"${key}"|'${key}'|\\b${key})\\s*:\\s*(?:"([^"]*)"|'([^']*)')`);
|
|
331
|
+
const m = re.exec(text.slice(from, to));
|
|
332
|
+
return m ? (m[1] ?? m[2] ?? '') : null;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* 定位 `abilities: [ { … } ]` 里第一个对象的字节区间。
|
|
337
|
+
*
|
|
338
|
+
* module.json5 顶层也有 icon / label(模块自身的),直接全文替换会改错对象,
|
|
339
|
+
* 所以先框出 abilities[0] 再在区间内改。用括号配对而不是正则:ability 对象内
|
|
340
|
+
* 嵌套了 skills 数组等结构,正则数不清层级。
|
|
341
|
+
*/
|
|
342
|
+
function firstAbilityRange(text) {
|
|
343
|
+
const key = /(?:"abilities"|'abilities'|\babilities)\s*:\s*\[/.exec(text);
|
|
344
|
+
if (!key) return null;
|
|
345
|
+
let i = key.index + key[0].length;
|
|
346
|
+
while (i < text.length && /\s/.test(text[i])) i++;
|
|
347
|
+
if (text[i] !== '{') return null;
|
|
348
|
+
const start = i;
|
|
349
|
+
let depth = 0;
|
|
350
|
+
let inStr = null;
|
|
351
|
+
for (; i < text.length; i++) {
|
|
352
|
+
const ch = text[i];
|
|
353
|
+
if (inStr) {
|
|
354
|
+
if (ch === '\\') { i++; continue; }
|
|
355
|
+
if (ch === inStr) inStr = null;
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
if (ch === '"' || ch === "'") { inStr = ch; continue; }
|
|
359
|
+
if (ch === '{') depth++;
|
|
360
|
+
else if (ch === '}') { depth--; if (depth === 0) return { start, end: i + 1 }; }
|
|
361
|
+
}
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ─────────────────────────────────────────────
|
|
366
|
+
// element/string.json 写入
|
|
367
|
+
// ─────────────────────────────────────────────
|
|
368
|
+
|
|
369
|
+
/** 合并写入一条 string 条目,保留文件里已有的其它条目。 */
|
|
370
|
+
function upsertString(file, name, value, dryRun) {
|
|
371
|
+
let doc = { string: [] };
|
|
372
|
+
if (fs.existsSync(file)) {
|
|
373
|
+
try {
|
|
374
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
375
|
+
if (parsed && Array.isArray(parsed.string)) doc = parsed;
|
|
376
|
+
} catch (e) {
|
|
377
|
+
fail(`${file} 不是合法 JSON,无法写入应用名: ${e.message}`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
const existing = doc.string.find((s) => s && s.name === name);
|
|
381
|
+
if (existing) existing.value = value;
|
|
382
|
+
else doc.string.push({ name, value });
|
|
383
|
+
if (!dryRun) {
|
|
384
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
385
|
+
fs.writeFileSync(file, `${JSON.stringify(doc, null, 2)}\n`, 'utf8');
|
|
386
|
+
}
|
|
387
|
+
return Boolean(existing);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// ─────────────────────────────────────────────
|
|
391
|
+
// 媒体拷贝
|
|
392
|
+
// ─────────────────────────────────────────────
|
|
393
|
+
|
|
394
|
+
/** 在某个 media 目录里找 `<name>.<任意扩展名>`。 */
|
|
395
|
+
function findMedia(mediaDir, name) {
|
|
396
|
+
if (!fs.existsSync(mediaDir)) return null;
|
|
397
|
+
for (const f of fs.readdirSync(mediaDir)) {
|
|
398
|
+
if (path.basename(f, path.extname(f)) === name) return path.join(mediaDir, f);
|
|
399
|
+
}
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function copyInto(src, destDir, destName, dryRun, written) {
|
|
404
|
+
const dest = path.join(destDir, destName);
|
|
405
|
+
if (!dryRun) {
|
|
406
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
407
|
+
fs.copyFileSync(src, dest);
|
|
408
|
+
}
|
|
409
|
+
written.push(dest);
|
|
410
|
+
return dest;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* 把 `<name>_layered_image.json` 及其 background / foreground 两层引用到的媒体
|
|
415
|
+
* 文件一起拷进 AppScope。只拷 json 不拷层文件的话,AppScope 侧引用悬空,
|
|
416
|
+
* 图标渲染为空白——而 json 本身存在且合法,任何存在性检查都发现不了。
|
|
417
|
+
*/
|
|
418
|
+
function copyLayeredImage(uiMediaDir, appScopeMediaDir, jsonName, dryRun, written, warnings) {
|
|
419
|
+
const src = path.join(uiMediaDir, jsonName);
|
|
420
|
+
if (!fs.existsSync(src)) return false;
|
|
421
|
+
copyInto(src, appScopeMediaDir, jsonName, dryRun, written);
|
|
422
|
+
|
|
423
|
+
let doc;
|
|
424
|
+
try { doc = JSON.parse(fs.readFileSync(src, 'utf8')); }
|
|
425
|
+
catch (e) { warnings.push(`${jsonName} 解析失败,层文件未随拷: ${e.message}`); return true; }
|
|
426
|
+
const layers = (doc && doc['layered-image']) || {};
|
|
427
|
+
for (const layer of ['background', 'foreground']) {
|
|
428
|
+
const ref = layers[layer];
|
|
429
|
+
if (typeof ref !== 'string') continue;
|
|
430
|
+
const m = /^\$media:(.+)$/.exec(ref);
|
|
431
|
+
if (!m) { warnings.push(`${jsonName} 的 ${layer} 引用 ${ref} 非 $media 形式,未随拷`); continue; }
|
|
432
|
+
const layerSrc = findMedia(uiMediaDir, m[1]);
|
|
433
|
+
if (!layerSrc) { warnings.push(`${jsonName} 的 ${layer} 引用 ${m[1]} 在 ui 模块 media 下不存在,未随拷`); continue; }
|
|
434
|
+
copyInto(layerSrc, appScopeMediaDir, path.basename(layerSrc), dryRun, written);
|
|
435
|
+
}
|
|
436
|
+
return true;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ─────────────────────────────────────────────
|
|
440
|
+
// 主流程
|
|
441
|
+
// ─────────────────────────────────────────────
|
|
442
|
+
|
|
443
|
+
function parseArgs(argv) {
|
|
444
|
+
const args = {};
|
|
445
|
+
for (let i = 0; i < argv.length; i++) {
|
|
446
|
+
const a = argv[i];
|
|
447
|
+
if (!a.startsWith('--')) fail(`无法识别的参数: ${a}`);
|
|
448
|
+
const next = argv[i + 1];
|
|
449
|
+
if (next && !next.startsWith('--')) { args[a.slice(2)] = next; i++; }
|
|
450
|
+
else args[a.slice(2)] = true;
|
|
451
|
+
}
|
|
452
|
+
return args;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function moduleSegs(uiModule) {
|
|
456
|
+
return uiModule.split(/[\\/]+/).filter(Boolean);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function run(args) {
|
|
460
|
+
const manifestPath = typeof args.manifest === 'string' ? args.manifest : fail('缺少 --manifest');
|
|
461
|
+
const resDir = typeof args.res === 'string' ? args.res : fail('缺少 --res');
|
|
462
|
+
const harmony = typeof args.harmony === 'string' ? args.harmony : fail('缺少 --harmony');
|
|
463
|
+
const uiModule = typeof args['ui-module'] === 'string' ? args['ui-module'] : 'entry';
|
|
464
|
+
const dryRun = Boolean(args['dry-run']);
|
|
465
|
+
if (!fs.existsSync(resDir)) fail(`--res 路径不存在: ${resDir}`);
|
|
466
|
+
|
|
467
|
+
const appScope = path.join(harmony, 'AppScope');
|
|
468
|
+
const appJson5 = path.join(appScope, 'app.json5');
|
|
469
|
+
const appScopeMedia = path.join(appScope, 'resources', 'base', 'media');
|
|
470
|
+
const uiResources = path.join(harmony, ...moduleSegs(uiModule), 'src', 'main', 'resources');
|
|
471
|
+
const uiMedia = path.join(uiResources, 'base', 'media');
|
|
472
|
+
const moduleJson5 = path.join(harmony, ...moduleSegs(uiModule), 'src', 'main', 'module.json5');
|
|
473
|
+
if (!fs.existsSync(appJson5)) fail(`找不到 ${appJson5}——确认 --harmony 指向 HarmonyOS 工程根目录`);
|
|
474
|
+
|
|
475
|
+
const warnings = [];
|
|
476
|
+
const written = [];
|
|
477
|
+
const ident = readManifestIdentity(manifestPath);
|
|
478
|
+
const drawables = indexDrawables(resDir);
|
|
479
|
+
|
|
480
|
+
// ── 应用图标 ──
|
|
481
|
+
const iconName = drawableRefName(ident.icon) ?? drawableRefName(ident.roundIcon);
|
|
482
|
+
let appIconRef = null;
|
|
483
|
+
let startWindowIconRef = null;
|
|
484
|
+
if (!iconName) {
|
|
485
|
+
warnings.push(`AndroidManifest 的 <application android:icon> 取值为 "${ident.icon}",`
|
|
486
|
+
+ '不是 @drawable/@mipmap 引用,无法定位启动图标——应用图标保持原值');
|
|
487
|
+
} else {
|
|
488
|
+
const candidates = drawables.get(iconName) ?? [];
|
|
489
|
+
if (candidates.length === 0) {
|
|
490
|
+
warnings.push(`启动图标 ${iconName} 在解码 res/ 下找不到任何文件——应用图标保持原值`);
|
|
491
|
+
} else {
|
|
492
|
+
// adaptive-icon(`mipmap-anydpi-v26/`)已由主转换产出 layered-image JSON,
|
|
493
|
+
// 这里复用它;没有 adaptive-icon 才退到最高密度位图。
|
|
494
|
+
const layeredName = `${iconName}_layered_image.json`;
|
|
495
|
+
const hasAdaptive = candidates.some((c) => c.ext === '.xml')
|
|
496
|
+
&& copyLayeredImage(uiMedia, appScopeMedia, layeredName, dryRun, written, warnings);
|
|
497
|
+
if (hasAdaptive) appIconRef = `$media:${iconName}_layered_image`;
|
|
498
|
+
|
|
499
|
+
const bitmap = bestByDensity(candidates.filter((c) => IMAGE_EXT.has(c.ext)));
|
|
500
|
+
if (bitmap) {
|
|
501
|
+
const destName = `${iconName}${bitmap.ext}`;
|
|
502
|
+
copyInto(bitmap.abs, appScopeMedia, destName, dryRun, written);
|
|
503
|
+
startWindowIconRef = `$media:${iconName}`;
|
|
504
|
+
if (!appIconRef) appIconRef = `$media:${iconName}`;
|
|
505
|
+
} else if (hasAdaptive) {
|
|
506
|
+
// 只有 adaptive-icon、没有位图:startWindowIcon 需要单张图片,
|
|
507
|
+
// 退到 foreground 层(layered JSON 不能直接当 startWindowIcon 用)。
|
|
508
|
+
const fg = findMedia(appScopeMedia, `${iconName}_foreground`)
|
|
509
|
+
?? findMedia(uiMedia, `${iconName}_foreground`);
|
|
510
|
+
if (fg) startWindowIconRef = `$media:${path.basename(fg, path.extname(fg))}`;
|
|
511
|
+
else warnings.push(`${iconName} 只有 adaptive-icon 且无 foreground 位图,startWindowIcon 保持原值`);
|
|
512
|
+
} else {
|
|
513
|
+
warnings.push(`启动图标 ${iconName} 无可用位图或 layered-image——应用图标保持原值`);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// ── 应用名 ──
|
|
519
|
+
const labelKey = stringRefName(ident.label);
|
|
520
|
+
const labelName = labelKey ?? 'app_name';
|
|
521
|
+
let appLabelRef = null;
|
|
522
|
+
let resolvedLabel = null;
|
|
523
|
+
const labelLocales = [];
|
|
524
|
+
if (labelKey) {
|
|
525
|
+
const variants = collectLabelStrings(resDir, labelKey);
|
|
526
|
+
if (variants.size === 0) {
|
|
527
|
+
warnings.push(`应用名 @string/${labelKey} 在解码 res/ 的 values 下找不到——应用名保持原值`);
|
|
528
|
+
} else {
|
|
529
|
+
for (const [qdir, value] of variants) {
|
|
530
|
+
upsertString(path.join(appScope, 'resources', qdir, 'element', 'string.json'), labelName, value, dryRun);
|
|
531
|
+
labelLocales.push(qdir);
|
|
532
|
+
if (qdir === 'base') resolvedLabel = value;
|
|
533
|
+
}
|
|
534
|
+
if (resolvedLabel === null) resolvedLabel = variants.values().next().value;
|
|
535
|
+
appLabelRef = `$string:${labelName}`;
|
|
536
|
+
}
|
|
537
|
+
} else if (ident.label) {
|
|
538
|
+
// manifest 里写的是字面量(或解码时已被 aapt 解析):仍要落成资源,
|
|
539
|
+
// app.json5 的 label 只接受 $string: 引用形式。
|
|
540
|
+
resolvedLabel = normalizeString(ident.label);
|
|
541
|
+
upsertString(path.join(appScope, 'resources', 'base', 'element', 'string.json'), labelName, resolvedLabel, dryRun);
|
|
542
|
+
labelLocales.push('base');
|
|
543
|
+
appLabelRef = `$string:${labelName}`;
|
|
544
|
+
} else {
|
|
545
|
+
warnings.push('AndroidManifest 的 <application> 没有 android:label——应用名保持原值');
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// ── 回写 AppScope/app.json5 ──
|
|
549
|
+
const patches = [];
|
|
550
|
+
let appText = fs.readFileSync(appJson5, 'utf8');
|
|
551
|
+
for (const [key, value] of [['icon', appIconRef], ['label', appLabelRef]]) {
|
|
552
|
+
if (!value) continue;
|
|
553
|
+
const r = patchJson5Value(appText, key, value);
|
|
554
|
+
appText = r.text;
|
|
555
|
+
patches.push({
|
|
556
|
+
file: 'AppScope/app.json5', key, newValue: value,
|
|
557
|
+
oldValue: r.oldValue, changed: r.changed, alreadySet: Boolean(r.alreadySet),
|
|
558
|
+
});
|
|
559
|
+
if (!r.changed && !r.alreadySet) warnings.push(`AppScope/app.json5 中找不到 ${key} 键,未改写`);
|
|
560
|
+
}
|
|
561
|
+
if (!dryRun) fs.writeFileSync(appJson5, appText, 'utf8');
|
|
562
|
+
|
|
563
|
+
// ── 回写 <ui_module>/src/main/module.json5 的 abilities[0] ──
|
|
564
|
+
// 这里的 icon / label 解析域是模块自身的 resources,主转换已经写进去了,
|
|
565
|
+
// 因此只需引用,不需要再拷一份。
|
|
566
|
+
const abilityPatches = [];
|
|
567
|
+
if (fs.existsSync(moduleJson5)) {
|
|
568
|
+
let modText = fs.readFileSync(moduleJson5, 'utf8');
|
|
569
|
+
const abilityIconName = appIconRef && findMedia(uiMedia, `${iconName}_layered_image`)
|
|
570
|
+
? `${iconName}_layered_image`
|
|
571
|
+
: (iconName && findMedia(uiMedia, iconName) ? iconName : null);
|
|
572
|
+
const abilityStartIcon = iconName && findMedia(uiMedia, iconName) ? iconName : abilityIconName;
|
|
573
|
+
// 主转换本该把这个图标写进 ui 模块的 media;找不到说明两步的输入不一致,
|
|
574
|
+
// 静默跳过等于把「桌面图标是默认值」这个缺陷原样留下——必须报出来。
|
|
575
|
+
if (iconName && !abilityIconName) {
|
|
576
|
+
warnings.push(`${iconName} 在 ${uiModule} 的 base/media 下不存在(主转换未产出?),`
|
|
577
|
+
+ 'ability 的 icon/startWindowIcon 未改写');
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// ability 的 label 引用模块自身的 string.json;主转换通常已写入该键,
|
|
581
|
+
// 缺失时补一条——否则 module.json5 引用悬空,编译期就报错。
|
|
582
|
+
if (appLabelRef && resolvedLabel !== null) {
|
|
583
|
+
const uiStringJson = path.join(uiResources, 'base', 'element', 'string.json');
|
|
584
|
+
upsertString(uiStringJson, labelName, resolvedLabel, dryRun);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
const range = firstAbilityRange(modText);
|
|
588
|
+
if (!range) {
|
|
589
|
+
warnings.push(`${moduleJson5} 中定位不到 abilities[0],ability 的 icon/label 未改写`);
|
|
590
|
+
} else {
|
|
591
|
+
let offsetDelta = 0;
|
|
592
|
+
for (const [key, value] of [
|
|
593
|
+
['icon', abilityIconName ? `$media:${abilityIconName}` : null],
|
|
594
|
+
['label', appLabelRef],
|
|
595
|
+
['startWindowIcon', abilityStartIcon ? `$media:${abilityStartIcon}` : null],
|
|
596
|
+
]) {
|
|
597
|
+
if (!value) continue;
|
|
598
|
+
const r = patchJson5Value(modText, key, value, range.start + offsetDelta, range.end + offsetDelta);
|
|
599
|
+
if (r.changed) offsetDelta += r.text.length - modText.length;
|
|
600
|
+
modText = r.text;
|
|
601
|
+
abilityPatches.push({
|
|
602
|
+
file: path.posix.join(...moduleSegs(uiModule), 'src/main/module.json5'),
|
|
603
|
+
key, newValue: value, oldValue: r.oldValue,
|
|
604
|
+
changed: r.changed, alreadySet: Boolean(r.alreadySet),
|
|
605
|
+
});
|
|
606
|
+
if (!r.changed && !r.alreadySet) warnings.push(`module.json5 的 abilities[0] 中找不到 ${key} 键,未改写`);
|
|
607
|
+
}
|
|
608
|
+
if (!dryRun) fs.writeFileSync(moduleJson5, modText, 'utf8');
|
|
609
|
+
}
|
|
610
|
+
} else {
|
|
611
|
+
warnings.push(`找不到 ${moduleJson5}——ability 的 icon/label 未改写`);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const record = {
|
|
615
|
+
harmony, uiModule, dryRun,
|
|
616
|
+
android: { icon: ident.icon, roundIcon: ident.roundIcon, label: ident.label },
|
|
617
|
+
resolved: {
|
|
618
|
+
iconName, appIcon: appIconRef, appLabel: appLabelRef,
|
|
619
|
+
labelName, labelValue: resolvedLabel, labelQualifiers: labelLocales,
|
|
620
|
+
startWindowIcon: startWindowIconRef,
|
|
621
|
+
},
|
|
622
|
+
patches: [...patches, ...abilityPatches],
|
|
623
|
+
appScopeFilesWritten: written.map((p) => path.relative(harmony, p).split(path.sep).join('/')),
|
|
624
|
+
warnings,
|
|
625
|
+
};
|
|
626
|
+
|
|
627
|
+
if (typeof args.json === 'string' && !dryRun) {
|
|
628
|
+
fs.writeFileSync(args.json, `${JSON.stringify(record, null, 1)}\n`, 'utf8');
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
if (!args.quiet) {
|
|
632
|
+
console.log(TAG + (dryRun ? ' (dry-run)' : ''));
|
|
633
|
+
console.log(` Android 图标 / 名字 : ${ident.icon || '(none)'} / ${ident.label || '(none)'}`);
|
|
634
|
+
console.log(` app.json5 icon : ${appIconRef ?? '(未改写)'}`);
|
|
635
|
+
console.log(` app.json5 label : ${appLabelRef ?? '(未改写)'}${resolvedLabel !== null ? ` = "${resolvedLabel}"` : ''}`);
|
|
636
|
+
console.log(` 应用名限定符 : ${labelLocales.join(', ') || '(none)'}`);
|
|
637
|
+
console.log(` AppScope 写入文件 : ${written.length}`);
|
|
638
|
+
console.log(` json5 字段改写 : ${[...patches, ...abilityPatches].filter((p) => p.changed).length}`);
|
|
639
|
+
for (const w of warnings) console.log(` ⚠️ ${w}`);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// 图标或名字任一没落地,就是用户可见的错误产物,按失败退出让调用方必须处理。
|
|
643
|
+
return appIconRef && appLabelRef ? 0 : 1;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* --check:复读两个 json5,确认它们仍指向 record 记录的值,且引用目标真实存在。
|
|
648
|
+
*
|
|
649
|
+
* 校验「与 record 一致」而不是「不等于模板默认值」:默认值随 DevEco 版本变化,
|
|
650
|
+
* 靠猜会漏。record 是本次运行从 AndroidManifest 实际解析出来的结果,比对它
|
|
651
|
+
* 才能同时抓住「没写成功」和「后续步骤把它改回去了」两种情况。
|
|
652
|
+
*/
|
|
653
|
+
function check(args) {
|
|
654
|
+
const jsonPath = typeof args.json === 'string' ? args.json : fail('--check 需要 --json <app_identity.json>');
|
|
655
|
+
if (!fs.existsSync(jsonPath)) fail(`--json 路径不存在: ${jsonPath}(先不带 --check 运行一次)`);
|
|
656
|
+
const rec = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
|
|
657
|
+
const harmony = rec.harmony;
|
|
658
|
+
const uiModule = rec.uiModule ?? 'entry';
|
|
659
|
+
const appScope = path.join(harmony, 'AppScope');
|
|
660
|
+
const uiResources = path.join(harmony, ...moduleSegs(uiModule), 'src', 'main', 'resources');
|
|
661
|
+
const failures = [];
|
|
662
|
+
|
|
663
|
+
/** `$media:x` / `$string:x` 是否能在给定 resources 根下解析到。 */
|
|
664
|
+
function resolvable(ref, resourcesRoot) {
|
|
665
|
+
const media = /^\$media:(.+)$/.exec(ref);
|
|
666
|
+
if (media) {
|
|
667
|
+
for (const qdir of fs.existsSync(resourcesRoot) ? fs.readdirSync(resourcesRoot) : []) {
|
|
668
|
+
if (findMedia(path.join(resourcesRoot, qdir, 'media'), media[1])) return true;
|
|
669
|
+
}
|
|
670
|
+
return false;
|
|
671
|
+
}
|
|
672
|
+
const str = /^\$string:(.+)$/.exec(ref);
|
|
673
|
+
if (str) {
|
|
674
|
+
for (const qdir of fs.existsSync(resourcesRoot) ? fs.readdirSync(resourcesRoot) : []) {
|
|
675
|
+
const f = path.join(resourcesRoot, qdir, 'element', 'string.json');
|
|
676
|
+
if (!fs.existsSync(f)) continue;
|
|
677
|
+
try {
|
|
678
|
+
const doc = JSON.parse(fs.readFileSync(f, 'utf8'));
|
|
679
|
+
if (Array.isArray(doc.string) && doc.string.some((s) => s && s.name === str[1])) return true;
|
|
680
|
+
} catch { /* 交给主转换的 JSON 校验去报 */ }
|
|
681
|
+
}
|
|
682
|
+
return false;
|
|
683
|
+
}
|
|
684
|
+
return false;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
const appJson5 = path.join(appScope, 'app.json5');
|
|
688
|
+
if (!fs.existsSync(appJson5)) failures.push(`缺少 ${appJson5}`);
|
|
689
|
+
else {
|
|
690
|
+
const text = fs.readFileSync(appJson5, 'utf8');
|
|
691
|
+
for (const [key, expect] of [['icon', rec.resolved?.appIcon], ['label', rec.resolved?.appLabel]]) {
|
|
692
|
+
if (!expect) { failures.push(`app.json5 的 ${key} 从未成功写入(本次转换未能从 AndroidManifest 解析出来)`); continue; }
|
|
693
|
+
const actual = readJson5Value(text, key);
|
|
694
|
+
if (actual !== expect) failures.push(`AppScope/app.json5 的 ${key} 是 "${actual}",期望 "${expect}"`);
|
|
695
|
+
else if (!resolvable(expect, path.join(appScope, 'resources'))) {
|
|
696
|
+
failures.push(`AppScope/app.json5 的 ${key} = "${expect}" 在 AppScope/resources 下解析不到目标`);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const moduleJson5 = path.join(harmony, ...moduleSegs(uiModule), 'src', 'main', 'module.json5');
|
|
702
|
+
if (!fs.existsSync(moduleJson5)) failures.push(`缺少 ${moduleJson5}`);
|
|
703
|
+
else {
|
|
704
|
+
const text = fs.readFileSync(moduleJson5, 'utf8');
|
|
705
|
+
const range = firstAbilityRange(text);
|
|
706
|
+
if (!range) failures.push(`${moduleJson5} 中定位不到 abilities[0]`);
|
|
707
|
+
else {
|
|
708
|
+
for (const p of (rec.patches ?? []).filter((x) => x.file.endsWith('module.json5'))) {
|
|
709
|
+
const actual = readJson5Value(text, p.key, range.start, range.end);
|
|
710
|
+
if (actual !== p.newValue) failures.push(`module.json5 abilities[0].${p.key} 是 "${actual}",期望 "${p.newValue}"`);
|
|
711
|
+
else if (!resolvable(p.newValue, uiResources)) {
|
|
712
|
+
failures.push(`module.json5 abilities[0].${p.key} = "${p.newValue}" 在 ${uiModule} 的 resources 下解析不到目标`);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
for (const rel of rec.appScopeFilesWritten ?? []) {
|
|
719
|
+
if (!fs.existsSync(path.join(harmony, rel))) failures.push(`记录已写入但文件不存在: ${rel}`);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
if (!args.quiet) {
|
|
723
|
+
console.log(`${TAG} --check`);
|
|
724
|
+
console.log(` 应用图标 : ${rec.resolved?.appIcon ?? '(未写入)'}`);
|
|
725
|
+
console.log(` 应用名 : ${rec.resolved?.appLabel ?? '(未写入)'}`
|
|
726
|
+
+ (rec.resolved?.labelValue != null ? ` = "${rec.resolved.labelValue}"` : ''));
|
|
727
|
+
if (failures.length === 0) console.log(' GATE: PASS');
|
|
728
|
+
else {
|
|
729
|
+
console.log(` GATE: FAIL(${failures.length} 项)`);
|
|
730
|
+
for (const f of failures) console.log(` ✗ ${f}`);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
return failures.length === 0 ? 0 : 1;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function main() {
|
|
737
|
+
const args = parseArgs(process.argv.slice(2));
|
|
738
|
+
process.exit(args.check ? check(args) : run(args));
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
main();
|