diff --git a/package.json b/package.json index 80a0481..c4897d8 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "pack": "zx script/pack.mjs", "start": "electron ./dist/main/index.js --inspect=9229", "lint": "eslint \"src/**/*.{ts,tsx}\"", + "test": "npx --yes tsx --test src/main/lib/hdc/bundleDump.test.ts", "init": "npm run resources", "resources": "zx script/resources.mjs", "format": "lsla prettier \"src/**/*.{ts,tsx,scss,css,json}\" \"*.{js,ts,json}\" \"script/*.mjs\" --write", diff --git a/src/main/lib/hdc/bundle.ts b/src/main/lib/hdc/bundle.ts index 911cacb..95dac67 100644 --- a/src/main/lib/hdc/bundle.ts +++ b/src/main/lib/hdc/bundle.ts @@ -21,6 +21,16 @@ import map from 'licia/map' import startWith from 'licia/startWith' import filter from 'licia/filter' import contain from 'licia/contain' +import path from 'node:path' +import os from 'node:os' +import fs from 'fs-extra' +import { + applyOnlineBundleFallback, + bundleInfoFromDump, + isLocalIconPath, + parseBmDumpOutput, + shouldFetchOnlineBundleInfo, +} from './bundleDump' const logger = log('hdcBundle') @@ -71,53 +81,41 @@ const getBundleInfos: IpcGetBundleInfos = async (connectKey, bundleNames) => { connectKey, map(bundleNames, (name) => `bm dump -n ${name}`) ) - const infos = map(dumpInfos, (dump) => { - const lines = dump.split('\n') - return JSON.parse(lines.slice(1).join('\n')) - }) + const infos = map(dumpInfos, (dump) => parseBmDumpOutput(dump)) for (let i = 0, len = bundleNames.length; i < len; i++) { const bundleName = bundleNames[i] - const bundleInfo: IBundleInfo = { - bundleName, - label: bundleName, - icon: '', - system: false, - versionName: '', - apiTargetVersion: 0, - vendor: '', - installTime: 0, - releaseType: '', - } + const bundleInfo = bundleInfoFromDump(bundleName, infos[i]) - const info = infos[i] - const applicationInfo = info.applicationInfo - bundleInfo.system = applicationInfo.isSystemApp - bundleInfo.versionName = applicationInfo.versionName - bundleInfo.apiTargetVersion = applicationInfo.apiTargetVersion - bundleInfo.vendor = applicationInfo.vendor - bundleInfo.installTime = info.installTime - bundleInfo.releaseType = info.releaseType - - const mainEntry = info.mainEntry - if (mainEntry) { - const mainModuleInfo = - info.hapModuleInfos[info.hapModuleNames.indexOf(mainEntry)] - bundleInfo.mainAbility = - mainModuleInfo.mainAbility || mainModuleInfo.abilityInfos[0].name + if (shouldFetchOnlineBundleInfo(bundleName, bundleInfo)) { + try { + const onlineInfo = await getOnlineBundleInfo(bundleName) + Object.assign( + bundleInfo, + applyOnlineBundleFallback(bundleInfo, onlineInfo) + ) + } catch (e) { + logger.error(e) + } } - if (!bundleInfo.system && !startWith(bundleName, 'com.huawei')) { + if (isLocalIconPath(bundleInfo.icon)) { try { - const onlineInfo = await getOnlineBundleInfo(bundleName) - if (onlineInfo.name) { - bundleInfo.label = onlineInfo.name - } - if (onlineInfo.icon) { - bundleInfo.icon = onlineInfo.icon - } + bundleInfo.icon = await loadLocalIcon(connectKey, bundleInfo.icon) } catch (e) { logger.error(e) + bundleInfo.icon = '' + if (shouldFetchOnlineBundleInfo(bundleName, bundleInfo)) { + try { + const onlineInfo = await getOnlineBundleInfo(bundleName) + Object.assign( + bundleInfo, + applyOnlineBundleFallback(bundleInfo, onlineInfo) + ) + } catch (onlineErr) { + logger.error(onlineErr) + } + } } } result.push(bundleInfo) @@ -146,6 +144,32 @@ async function getOnlineBundleInfo(bundleName: string) { return data } +async function loadLocalIcon( + connectKey: string, + iconPath: string +): Promise { + const target = await client.getTarget(connectKey) + const dest = path.resolve( + os.tmpdir(), + `echo-icon-${Date.now()}-${Math.random().toString(16).slice(2)}${path.extname(iconPath)}` + ) + await target.recvFile(iconPath, dest) + const buf = await fs.readFile(dest) + await fs.remove(dest).catch(() => {}) + const ext = path.extname(iconPath).toLowerCase() + const mime = + ext === '.webp' + ? 'image/webp' + : ext === '.jpg' || ext === '.jpeg' + ? 'image/jpeg' + : ext === '.svg' + ? 'image/svg+xml' + : ext === '.gif' + ? 'image/gif' + : 'image/png' + return `data:${mime};base64,${buf.toString('base64')}` +} + const installBundle: IpcInstallBundle = async (connectKey, hap) => { const target = await client.getTarget(connectKey) await target.install(hap) diff --git a/src/main/lib/hdc/bundleDump.test.ts b/src/main/lib/hdc/bundleDump.test.ts new file mode 100644 index 0000000..ca023a9 --- /dev/null +++ b/src/main/lib/hdc/bundleDump.test.ts @@ -0,0 +1,121 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { + applyOnlineBundleFallback, + bundleInfoFromDump, + isDisplayableLabel, + isLocalIconPath, + localDisplayFromDump, + parseBmDumpOutput, + shouldFetchOnlineBundleInfo, +} from './bundleDump' + +const PERSONAL_BUNDLE = 'com.tonycoder.personalapp' + +function personalDump(overrides: Record = {}) { + return { + installTime: 1710000000000, + releaseType: 'Release', + mainEntry: 'entry', + hapModuleNames: ['entry'], + hapModuleInfos: [ + { + name: 'entry', + mainAbility: 'EntryAbility', + label: '$string:module_label', + icon: '$media:module_icon', + abilityInfos: [{ name: 'EntryAbility' }], + }, + ], + applicationInfo: { + bundleName: PERSONAL_BUNDLE, + isSystemApp: false, + versionName: '1.2.3', + apiTargetVersion: 12, + vendor: 'Tony', + label: '个人工具', + icon: '$media:app_icon', + iconPath: + '/data/app/el1/bundle/public/com.tonycoder.personalapp/entry/resources/base/media/app_icon.png', + }, + ...overrides, + } +} + +describe('bundleDump from mocked bm dump JSON', () => { + it('parses bm dump -n text and prefers local label/iconPath when AppGallery has none', () => { + const dumpText = `${PERSONAL_BUNDLE}: +${JSON.stringify(personalDump(), null, 4)}` + + const dump = parseBmDumpOutput(dumpText) + const bundleInfo = bundleInfoFromDump(PERSONAL_BUNDLE, dump) + const merged = applyOnlineBundleFallback(bundleInfo, {}) + + assert.equal(merged.label, '个人工具') + assert.equal( + merged.icon, + '/data/app/el1/bundle/public/com.tonycoder.personalapp/entry/resources/base/media/app_icon.png' + ) + assert.equal(merged.versionName, '1.2.3') + assert.equal(merged.vendor, 'Tony') + assert.equal(merged.mainAbility, 'EntryAbility') + assert.equal(merged.system, false) + assert.equal(shouldFetchOnlineBundleInfo(PERSONAL_BUNDLE, merged), false) + }) + + it('keeps AppGallery as fallback when local dump has only resource refs', () => { + const dump = personalDump({ + applicationInfo: { + bundleName: PERSONAL_BUNDLE, + isSystemApp: false, + versionName: '0.0.1', + apiTargetVersion: 12, + vendor: '', + label: '$string:app_name', + icon: '$media:app_icon', + iconPath: '$media:app_icon', + }, + }) + const local = localDisplayFromDump(dump) + assert.equal(local.label, '') + assert.equal(local.icon, '') + + const bundleInfo = bundleInfoFromDump(PERSONAL_BUNDLE, dump) + assert.equal(bundleInfo.label, PERSONAL_BUNDLE) + assert.equal(bundleInfo.icon, '') + assert.equal(shouldFetchOnlineBundleInfo(PERSONAL_BUNDLE, bundleInfo), true) + + const merged = applyOnlineBundleFallback(bundleInfo, { + name: 'Gallery Name', + icon: 'https://appgallery.example/icon.png', + }) + assert.equal(merged.label, 'Gallery Name') + assert.equal(merged.icon, 'https://appgallery.example/icon.png') + }) + + it('does not let AppGallery overwrite a local dump label or icon path', () => { + const bundleInfo = bundleInfoFromDump(PERSONAL_BUNDLE, personalDump()) + const merged = applyOnlineBundleFallback(bundleInfo, { + name: 'Store Title', + icon: 'https://appgallery.example/store.png', + }) + + assert.equal(merged.label, '个人工具') + assert.equal( + merged.icon, + '/data/app/el1/bundle/public/com.tonycoder.personalapp/entry/resources/base/media/app_icon.png' + ) + }) + + it('treats resource refs as non-displayable and device iconPath as local', () => { + assert.equal(isDisplayableLabel('$string:app_name'), false) + assert.equal(isDisplayableLabel('个人工具'), true) + assert.equal(isLocalIconPath('$media:app_icon'), false) + assert.equal( + isLocalIconPath( + '/data/app/el1/bundle/public/com.example.app/resources/base/media/icon.png' + ), + true + ) + }) +}) diff --git a/src/main/lib/hdc/bundleDump.ts b/src/main/lib/hdc/bundleDump.ts new file mode 100644 index 0000000..c187748 --- /dev/null +++ b/src/main/lib/hdc/bundleDump.ts @@ -0,0 +1,201 @@ +import { IBundleInfo } from '../../../common/types' + +const RESOURCE_REF_RE = /^\$[a-z]+:/i +const IMAGE_PATH_RE = /\.(png|jpe?g|webp|gif|svg|bmp)$/i + +export function isResourceRef(value: string): boolean { + return RESOURCE_REF_RE.test(value) +} + +export function isDisplayableLabel(value?: string | null): boolean { + if (!value) { + return false + } + const label = value.trim() + return label.length > 0 && !isResourceRef(label) +} + +export function isLocalIconPath(value?: string | null): boolean { + if (!value) { + return false + } + const icon = value.trim() + if (!icon || isResourceRef(icon)) { + return false + } + if (icon.startsWith('/') || icon.startsWith('file:')) { + return !/\.json$/i.test(icon) + } + return IMAGE_PATH_RE.test(icon) +} + +export function isRemoteIcon(value?: string | null): boolean { + if (!value) { + return false + } + return /^(https?:|data:)/i.test(value.trim()) +} + +function firstDisplayableLabel(candidates: Array): string { + for (let i = 0, len = candidates.length; i < len; i++) { + const value = candidates[i] + if (isDisplayableLabel(value)) { + return (value as string).trim() + } + } + return '' +} + +function firstLocalIconPath(candidates: Array): string { + for (let i = 0, len = candidates.length; i < len; i++) { + const value = candidates[i] + if (isLocalIconPath(value)) { + return (value as string).trim() + } + } + return '' +} + +function getMainModule(dump: any) { + if (!dump) { + return null + } + const hapModuleInfos = dump.hapModuleInfos + if (!Array.isArray(hapModuleInfos) || hapModuleInfos.length === 0) { + return null + } + const mainEntry = dump.mainEntry + if (mainEntry && Array.isArray(dump.hapModuleNames)) { + const index = dump.hapModuleNames.indexOf(mainEntry) + if (index >= 0 && hapModuleInfos[index]) { + return hapModuleInfos[index] + } + } + return hapModuleInfos[0] +} + +function getMainAbilityInfo(mainModule: any) { + if (!mainModule) { + return null + } + const abilityInfos = mainModule.abilityInfos + if (!Array.isArray(abilityInfos) || abilityInfos.length === 0) { + return null + } + if (mainModule.mainAbility) { + const match = abilityInfos.find( + (ability: any) => + ability && + (ability.name === mainModule.mainAbility || + ability.name?.endsWith(mainModule.mainAbility)) + ) + if (match) { + return match + } + } + return abilityInfos[0] +} + +export function parseBmDumpOutput(dumpText: string): any { + const lines = dumpText.split('\n') + const jsonText = lines.slice(1).join('\n').trim() + if (!jsonText) { + throw new Error('empty bm dump json') + } + return JSON.parse(jsonText) +} + +export function localDisplayFromDump(dump: any): { + label: string + icon: string +} { + const applicationInfo = dump?.applicationInfo || {} + const mainModule = getMainModule(dump) + const mainAbility = getMainAbilityInfo(mainModule) + + return { + label: firstDisplayableLabel([ + applicationInfo.label, + mainModule?.label, + mainAbility?.label, + dump?.label, + ]), + icon: firstLocalIconPath([ + applicationInfo.iconPath, + applicationInfo.icon, + mainModule?.iconPath, + mainModule?.icon, + mainAbility?.iconPath, + mainAbility?.icon, + ]), + } +} + +export function bundleInfoFromDump(bundleName: string, dump: any): IBundleInfo { + const applicationInfo = dump?.applicationInfo || {} + const localDisplay = localDisplayFromDump(dump) + const bundleInfo: IBundleInfo = { + bundleName, + label: localDisplay.label || bundleName, + icon: localDisplay.icon, + system: !!applicationInfo.isSystemApp, + versionName: applicationInfo.versionName || '', + apiTargetVersion: applicationInfo.apiTargetVersion || 0, + vendor: applicationInfo.vendor || '', + installTime: dump?.installTime || 0, + releaseType: dump?.releaseType || '', + } + + const mainEntry = dump?.mainEntry + if (mainEntry && Array.isArray(dump.hapModuleInfos)) { + const mainModuleInfo = + dump.hapModuleInfos[dump.hapModuleNames.indexOf(mainEntry)] + if (mainModuleInfo) { + bundleInfo.mainAbility = + mainModuleInfo.mainAbility || mainModuleInfo.abilityInfos?.[0]?.name + } + } + + return bundleInfo +} + +export function applyOnlineBundleFallback( + bundleInfo: IBundleInfo, + onlineInfo?: { name?: string; icon?: string } | null +): IBundleInfo { + if (!onlineInfo) { + return bundleInfo + } + + const next = { ...bundleInfo } + const localLabel = localDisplayableLabel(bundleInfo) + if (!localLabel && onlineInfo.name) { + next.label = onlineInfo.name + } + if (!bundleInfo.icon && onlineInfo.icon) { + next.icon = onlineInfo.icon + } + return next +} + +function localDisplayableLabel(bundleInfo: IBundleInfo): boolean { + return ( + isDisplayableLabel(bundleInfo.label) && + bundleInfo.label !== bundleInfo.bundleName + ) +} + +export function shouldFetchOnlineBundleInfo( + bundleName: string, + bundleInfo: IBundleInfo +): boolean { + if (bundleInfo.system || bundleName.startsWith('com.huawei')) { + return false + } + const hasLocalLabel = + isDisplayableLabel(bundleInfo.label) && bundleInfo.label !== bundleName + const hasLocalIcon = !!( + isLocalIconPath(bundleInfo.icon) || isRemoteIcon(bundleInfo.icon) + ) + return !hasLocalLabel || !hasLocalIcon +}