Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
100 changes: 62 additions & 38 deletions src/main/lib/hdc/bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -146,6 +144,32 @@ async function getOnlineBundleInfo(bundleName: string) {
return data
}

async function loadLocalIcon(
connectKey: string,
iconPath: string
): Promise<string> {
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)
Expand Down
121 changes: 121 additions & 0 deletions src/main/lib/hdc/bundleDump.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}) {
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
)
})
})
Loading