feat: offline-package builtin icon + icon rounding; fix: async check-update, wallpaper cover scaling

This commit is contained in:
AskaEth committed 2026-08-18 19:09:33 +08:00
1 parent 885be2fe85
commit ef59941953
6 files changed
+114 -46

No files matched your search

+2 -2
View File
@@ -462,7 +462,7 @@ body {
color: var(--text);
}
.cell .ic { width: 48px; height: 48px; border-radius: 12px; }
.cell .ic { width: 48px; height: 48px; border-radius: 12px; object-fit: cover; display: block; }
.cell .lbl { font-size: 12px; color: var(--text-dim); text-align: center; }
/* H5 应用列表页:富卡片 + 在线/离线标签 */
@@ -481,7 +481,7 @@ body {
color: var(--text);
}
.wcell .ic { width: 48px; height: 48px; border-radius: 12px; }
.wcell .ic { width: 48px; height: 48px; border-radius: 12px; object-fit: cover; display: block; }
.wcell .lbl { font-size: 12px; color: var(--text-dim); text-align: center; }
.wcell .tag {
+16 -13
View File
@@ -226,24 +226,27 @@ function setupServerUrl(page) {
};
}
// 检查更新:触发重新拉取 webAPP 清单 + 卡片目录,完成后提示
// Check update: re-fetch the webAPP manifest + card catalog, then show a hint
// 检查更新:后台异步拉取,完成后通过 checkUpdateResult 事件回调更新提示
// Check update: async fetch on a background thread; the result is delivered via
// the checkUpdateResult event callback
function setupCheckUpdate(page) {
const item = page.querySelector('#check-update-item');
const state = page.querySelector('#check-update-state');
// 注册结果回调(原生 refreshManifest 完成后推送)
// Register the result callback (pushed by native refreshManifest when done)
window.HearthEvents = window.HearthEvents || {};
window.HearthEvents.checkUpdateResult = function (json) {
let r = json;
try { r = typeof json === 'string' ? JSON.parse(json) : json; } catch (e) { r = {}; }
const parts = [];
if (r.added > 0) parts.push('新增 ' + r.added);
if (r.removed > 0) parts.push('移除 ' + r.removed);
if (r.changed > 0) parts.push('更新 ' + r.changed);
state.textContent = parts.length ? parts.join(',') + ' ›' : '无更新 ›';
};
item.onclick = () => {
state.textContent = '检查中…';
bridge.call('refreshManifest').then(result => {
let r = { added: 0, removed: 0, changed: 0 };
try { r = JSON.parse(result); } catch (e) {}
const parts = [];
if (r.added > 0) parts.push('新增 ' + r.added);
if (r.removed > 0) parts.push('移除 ' + r.removed);
if (r.changed > 0) parts.push('更新 ' + r.changed);
state.textContent = parts.length ? parts.join(',') + ' ›' : '无更新 ›';
}).catch(() => {
state.textContent = '失败 ›';
});
bridge.call('refreshManifest'); // 异步,不 await 结果
};
}
@@ -149,9 +149,25 @@ class MainActivity : Activity() {
if (wm.wallpaperInfo != null) return ""
val d = wm.drawable ?: return ""
val dm = resources.displayMetrics
val bmp = Bitmap.createBitmap(dm.widthPixels, dm.heightPixels, Bitmap.Config.ARGB_8888)
val sw = dm.widthPixels
val sh = dm.heightPixels
val bmp = Bitmap.createBitmap(sw, sh, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bmp)
d.setBounds(0, 0, dm.widthPixels, dm.heightPixels)
// cover 缩放:保持宽高比铺满屏幕并居中裁剪,避免竖屏壁纸在横屏下
// 被拉伸或底部留黑边
// cover-scale: keep aspect ratio and center-crop to fill the screen,
// avoiding stretch or a bottom black band from portrait wallpaper on
// a landscape screen
val dw = d.intrinsicWidth
val dh = d.intrinsicHeight
if (dw > 0 && dh > 0) {
val scale = maxOf(sw.toFloat() / dw, sh.toFloat() / dh)
val w = (dw * scale).toInt()
val h = (dh * scale).toInt()
d.setBounds((sw - w) / 2, (sh - h) / 2, (sw - w) / 2 + w, (sh - h) / 2 + h)
} else {
d.setBounds(0, 0, sw, sh)
}
d.draw(canvas)
val out = ByteArrayOutputStream()
bmp.compress(Bitmap.CompressFormat.JPEG, 80, out)
@@ -16,6 +16,7 @@ data class WebApp(
val url: String,
val offline: String? = null,
val offlineVersion: String? = null,
val offlineIcon: String? = null,
val ua: String? = null,
val scale: Int? = null,
)
@@ -108,6 +109,10 @@ class WebAppRepository(
val offlineMap = map["offline"] as? Map<*, *>
val offline = offlineMap?.get("package") as? String
val offlineVersion = offlineMap?.get("version") as? String
// 离线包内图标路径(相对离线包根,如 icon.svg),本地包有图标时优先显示
// Icon path inside the offline package (relative to its root, e.g.
// icon.svg); used in preference when the local package has the icon
val offlineIcon = offlineMap?.get("icon") as? String
// ua 可选:desktop/pc 用 PC UA,其他视为自定义 UA 字符串,不指定默认平板 UA
// ua optional: desktop/pc uses a desktop UA, others are custom UA strings,
// absent means the default tablet UA
@@ -121,6 +126,7 @@ class WebAppRepository(
resolvePath(url),
offline?.let { resolvePath(it) },
offlineVersion,
offlineIcon,
ua,
scale,
)
@@ -42,6 +42,24 @@ class WebAppStorage(private val context: Context) {
return "file://" + path
}
// 本地离线包内图标 URL(file:// 绝对路径),图标文件不存在则 null。
// iconPath 是相对离线包根的路径(如 icon.svg),做路径穿越校验
// Local offline-package icon URL (file:// absolute path), null when the icon
// file doesn't exist. iconPath is relative to the package root (e.g. icon.svg),
// with a path-traversal guard
fun localIconUrl(id: String, iconPath: String): String? {
val name = iconPath.replace('\\', '/').trimStart('/')
if (name.contains("..") || name.isEmpty()) return null
val f = File(appDir(id), name)
if (!f.isFile) return null
val path = try {
f.canonicalPath
} catch (e: Exception) {
f.absolutePath
}
return "file://" + path
}
// 本地离线包版本号(解压目录里的 .version 文件),无则 null
// Local offline-package version (from the .version file), null when absent
fun getLocalVersion(id: String): String? {
@@ -120,14 +120,27 @@ class JsBridge(
fun launchApp(packageName: String): Boolean = appRepository?.launchApp(packageName) ?: false
// 返回 H5 应用清单 JSON(无仓库时返回空数组),并写入内存缓存供 openWebApp 复用
// 本地包内置图标优先:离线包内有 offlineIcon 且本地已安装时,用本地 file:// 图标
// Return the H5 web app manifest JSON (empty array when no repository wired) and
// seed the in-memory cache for openWebApp reuse
// seed the in-memory cache for openWebApp reuse. Local package icon wins: when the
// offline package declares offlineIcon and is installed locally, use the local icon
@android.webkit.JavascriptInterface
fun fetchWebApps(): String {
val apps = manifest()
val apps = manifest().map { app ->
resolveLocalIcon(app)?.let { app.copy(icon = it) } ?: app
}
return gson.toJson(apps)
}
// 解析本地离线包图标:offlineIcon 指定且本地文件存在 → file:// URL,否则 null
// Resolve the local offline-package icon: when offlineIcon is set and the local
// file exists, return its file:// URL; otherwise null
private fun resolveLocalIcon(app: WebApp): String? {
val iconPath = app.offlineIcon ?: return null
val st = storage ?: return null
return st.localIconUrl(app.id, iconPath)
}
// 打开指定 id 的 webAPP:本地离线包优先 → 有离线包则下载解压(带进度)→ 否则远程加载
// Open a web app by id: prefer the local offline package -> download+extract (with
// progress) if it has an offline package -> otherwise load the remote URL
@@ -419,38 +432,50 @@ class JsBridge(
@android.webkit.JavascriptInterface
fun getWallpaper(): String = wallpaper?.getWallpaperBase64() ?: ""
// 重新拉取 webAPP 清单 + 卡片目录,返回 { added, removed, changed }
// Re-fetch the webAPP manifest + card catalog, return { added, removed, changed }
// 重新拉取 webAPP 清单 + 卡片目录(后台线程,避免阻塞 JS 线程导致卡顿),
// 完成后通过 HearthEvents.checkUpdateResult 推送 { added, removed, changed }
// Re-fetch the webAPP manifest + card catalog on a background thread (avoid
// blocking the JS thread), then push { added, removed, changed } via
// HearthEvents.checkUpdateResult
@android.webkit.JavascriptInterface
fun refreshManifest(): String {
val oldApps = manifestCache ?: emptyList<WebApp>()
manifestCache = null
val apps = webAppRepository?.fetchManifest() ?: emptyList<WebApp>()
if (apps.isNotEmpty()) manifestCache = apps
cardRepository?.fetchCatalog()
Thread {
val oldApps = manifestCache ?: emptyList<WebApp>()
manifestCache = null
val apps = webAppRepository?.fetchManifest() ?: emptyList<WebApp>()
if (apps.isNotEmpty()) manifestCache = apps
cardRepository?.fetchCatalog()
// 增量计算:新增 / 移除 / 更新(离线包版本或 URL/图标变化)
// Diff: added / removed / changed (offline version or URL/icon changed)
val oldIds = oldApps.map { it.id }.toSet()
val newIds = apps.map { it.id }.toSet()
val added = (newIds - oldIds).size
val removed = (oldIds - newIds).size
val changed = apps.count { new ->
val old = oldApps.find { it.id == new.id }
old != null && (old.offlineVersion != new.offlineVersion ||
old.url != new.url || old.icon != new.icon)
}
Log.d("HearthBridge", "refreshManifest: added=$added removed=$removed changed=$changed (${apps.size} apps)")
// 增量计算:新增 / 移除 / 更新(离线包版本或 URL/图标变化)
// Diff: added / removed / changed (offline version or URL/icon changed)
val oldIds = oldApps.map { it.id }.toSet()
val newIds = apps.map { it.id }.toSet()
val added = (newIds - oldIds).size
val removed = (oldIds - newIds).size
val changed = apps.count { new ->
val old = oldApps.find { it.id == new.id }
old != null && (old.offlineVersion != new.offlineVersion ||
old.url != new.url || old.icon != new.icon)
}
Log.d("HearthBridge", "refreshManifest: added=$added removed=$removed changed=$changed (${apps.size} apps)")
// 推送 H5 刷新列表(清 preload 缓存并重渲染)
// Push H5 to refresh the list (clear preload cache and re-render)
desktopWebViewRef?.post {
desktopWebViewRef?.evaluateJavascript(
"window.HearthEvents && window.HearthEvents.webappUpdated();",
null
val resultJson = gson.toJson(
mapOf("added" to added, "removed" to removed, "changed" to changed)
)
}
return gson.toJson(mapOf("added" to added, "removed" to removed, "changed" to changed))
// 推送结果 + 刷新列表(清 preload 缓存并重渲染)
// Push the result + refresh the list (clear preload cache and re-render)
desktopWebViewRef?.post {
desktopWebViewRef?.evaluateJavascript(
"window.HearthEvents && window.HearthEvents.checkUpdateResult($resultJson);",
null
)
desktopWebViewRef?.evaluateJavascript(
"window.HearthEvents && window.HearthEvents.webappUpdated();",
null
)
}
}.start()
return "checking"
}
// 注册媒体会话监听,回调推送给 H5(info 为 null 时推 "null")