feat: offline package version management, long-press menu
This commit is contained in:
@@ -538,6 +538,33 @@ body.webapp-active [data-page="webapplist"] {
|
|||||||
transition: width .2s ease;
|
transition: width .2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* webapp 长按管理菜单 */
|
||||||
|
/* webapp long-press management menu */
|
||||||
|
.wapp-menu {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 300;
|
||||||
|
min-width: 180px;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: var(--glass-bg);
|
||||||
|
backdrop-filter: var(--blur-filter);
|
||||||
|
-webkit-backdrop-filter: var(--blur-filter);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, .3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wapp-menu-item {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wapp-menu-item:active {
|
||||||
|
background: var(--glass-border);
|
||||||
|
}
|
||||||
|
|
||||||
#web-topbar {
|
#web-topbar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 12px;
|
top: 12px;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// H5 应用列表页:富卡片 + 在线/离线标签 + 搜索 + 长按导入离线包
|
// H5 应用列表页:富卡片 + 在线/离线/可更新标签 + 搜索 + 长按菜单(更新/删除/导入/属性)
|
||||||
// H5 web app list page: rich cards + online/offline badge + search + long-press import
|
// H5 web app list page: rich cards + online/offline/updatable badge + search +
|
||||||
|
// long-press menu (update/delete/import/properties)
|
||||||
window.loadWebAppList = async function () {
|
window.loadWebAppList = async function () {
|
||||||
const page = document.querySelector('[data-page="webapplist"]');
|
const page = document.querySelector('[data-page="webapplist"]');
|
||||||
if (page.dataset.loaded) return; page.dataset.loaded = '1';
|
if (page.dataset.loaded) return; page.dataset.loaded = '1';
|
||||||
@@ -12,8 +13,6 @@ window.loadWebAppList = async function () {
|
|||||||
const grid = document.getElementById('web-grid');
|
const grid = document.getElementById('web-grid');
|
||||||
const render = (list) => {
|
const render = (list) => {
|
||||||
grid.textContent = '';
|
grid.textContent = '';
|
||||||
// 用 createElement + textContent/dataset 渲染,避免 innerHTML 拼接远端数据(XSS)
|
|
||||||
// Render with createElement + textContent/dataset to avoid innerHTML remote data (XSS)
|
|
||||||
list.forEach(a => {
|
list.forEach(a => {
|
||||||
const btn = document.createElement('button');
|
const btn = document.createElement('button');
|
||||||
btn.className = 'wcell';
|
btn.className = 'wcell';
|
||||||
@@ -31,13 +30,10 @@ window.loadWebAppList = async function () {
|
|||||||
tag.textContent = a.offline ? '离线' : '在线';
|
tag.textContent = a.offline ? '离线' : '在线';
|
||||||
btn.append(img, lbl, tag);
|
btn.append(img, lbl, tag);
|
||||||
btn.onclick = () => bridge.call('openWebApp', btn.dataset.id);
|
btn.onclick = () => bridge.call('openWebApp', btn.dataset.id);
|
||||||
// 长按导入离线包
|
setupLongPress(btn, a);
|
||||||
setupImportLongPress(btn, a.id);
|
|
||||||
grid.appendChild(btn);
|
grid.appendChild(btn);
|
||||||
// 异步检查本地离线包状态,更新标签
|
// 异步检查本地离线包状态 + 版本对比,更新标签
|
||||||
bridge.call('hasLocalPackage', a.id).then(has => {
|
refreshBadge(tag, a);
|
||||||
if (has) tag.textContent = '离线';
|
|
||||||
}).catch(() => {});
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
render(apps);
|
render(apps);
|
||||||
@@ -47,17 +43,90 @@ window.loadWebAppList = async function () {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// 长按(600ms)导入离线包
|
// 异步刷新列表项标签(离线 / 可更新)
|
||||||
// Long-press (600ms) to import an offline package
|
// Refresh the badge asynchronously (offline / updatable)
|
||||||
function setupImportLongPress(btn, id) {
|
async function refreshBadge(tag, app) {
|
||||||
|
try {
|
||||||
|
const hasLocal = await bridge.call('hasLocalPackage', app.id);
|
||||||
|
const localVer = await bridge.call('getLocalVersion', app.id);
|
||||||
|
const cloudVer = app.offlineVersion || '';
|
||||||
|
if (hasLocal && cloudVer && cloudVer !== localVer) {
|
||||||
|
tag.textContent = '可更新';
|
||||||
|
} else if (hasLocal) {
|
||||||
|
tag.textContent = '离线';
|
||||||
|
} else if (app.offline) {
|
||||||
|
tag.textContent = '离线';
|
||||||
|
} else {
|
||||||
|
tag.textContent = '在线';
|
||||||
|
}
|
||||||
|
} catch (e) { /* 忽略 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 长按(600ms)弹出管理菜单
|
||||||
|
// Long-press (600ms) to show the management menu
|
||||||
|
function setupLongPress(btn, app) {
|
||||||
let timer = null;
|
let timer = null;
|
||||||
btn.addEventListener('touchstart', () => {
|
const start = (e) => {
|
||||||
timer = setTimeout(() => {
|
timer = setTimeout(() => {
|
||||||
bridge.call('importOfflinePackage', id).catch(() => {});
|
showWebAppMenu(app, e.touches[0].clientX, e.touches[0].clientY);
|
||||||
}, 600);
|
}, 600);
|
||||||
}, { passive: true });
|
};
|
||||||
btn.addEventListener('touchend', () => { if (timer) clearTimeout(timer); }, { passive: true });
|
const cancel = () => { if (timer) clearTimeout(timer); };
|
||||||
btn.addEventListener('touchmove', () => { if (timer) clearTimeout(timer); }, { passive: true });
|
btn.addEventListener('touchstart', start, { passive: true });
|
||||||
|
btn.addEventListener('touchend', cancel, { passive: true });
|
||||||
|
btn.addEventListener('touchmove', cancel, { passive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 弹出管理菜单:更新 / 删除 / 导入 / 属性
|
||||||
|
// Show the management menu: update / delete / import / properties
|
||||||
|
async function showWebAppMenu(app, x, y) {
|
||||||
|
document.querySelectorAll('.wapp-menu').forEach(m => m.remove());
|
||||||
|
const hasLocal = await bridge.call('hasLocalPackage', app.id);
|
||||||
|
const localVer = await bridge.call('getLocalVersion', app.id);
|
||||||
|
const cloudVer = app.offlineVersion || '';
|
||||||
|
|
||||||
|
const menu = document.createElement('div');
|
||||||
|
menu.className = 'wapp-menu';
|
||||||
|
const addItem = (label, onClick) => {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'wapp-menu-item';
|
||||||
|
item.textContent = label;
|
||||||
|
item.onclick = () => { menu.remove(); if (onClick) onClick(); };
|
||||||
|
menu.appendChild(item);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 更新(本地有包 + 云端版本不同)
|
||||||
|
if (hasLocal && cloudVer && cloudVer !== localVer) {
|
||||||
|
addItem('更新离线包(' + (localVer || '?') + ' → ' + cloudVer + ')', () => {
|
||||||
|
bridge.call('updateOfflinePackage', app.id).catch(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 删除(本地有包)
|
||||||
|
if (hasLocal) {
|
||||||
|
addItem('删除本地包', () => {
|
||||||
|
bridge.call('deleteLocalPackage', app.id).then(() => refreshList()).catch(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 导入
|
||||||
|
addItem('导入离线包', () => {
|
||||||
|
bridge.call('importOfflinePackage', app.id).catch(() => {});
|
||||||
|
});
|
||||||
|
// 属性
|
||||||
|
addItem('属性:本地 ' + (localVer || '无') + ' / 云端 ' + (cloudVer || '无'), null);
|
||||||
|
|
||||||
|
menu.style.left = Math.min(x, window.innerWidth - 220) + 'px';
|
||||||
|
menu.style.top = Math.min(y, window.innerHeight - 180) + 'px';
|
||||||
|
document.body.appendChild(menu);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 刷新列表(删除/导入后重新加载)
|
||||||
|
// Refresh the list (reload after delete/import)
|
||||||
|
function refreshList() {
|
||||||
|
const page = document.querySelector('[data-page="webapplist"]');
|
||||||
|
if (page) {
|
||||||
|
page.dataset.loaded = '';
|
||||||
|
if (page.classList.contains('active')) window.loadWebAppList();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// webapp 加载/下载进度:显示覆盖层
|
// webapp 加载/下载进度:显示覆盖层
|
||||||
@@ -74,13 +143,9 @@ window.HearthEvents.webappProgress = function (id, progress) {
|
|||||||
loading.querySelector('.wl-fill').style.width = progress + '%';
|
loading.querySelector('.wl-fill').style.width = progress + '%';
|
||||||
};
|
};
|
||||||
|
|
||||||
// 导入完成:刷新列表(离线标签更新)
|
// 导入完成:刷新列表
|
||||||
// Import complete: refresh the list (badge updates)
|
// Import complete: refresh the list
|
||||||
window.HearthEvents.webappImported = function (id, ok) {
|
window.HearthEvents.webappImported = function (id, ok) {
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
const page = document.querySelector('[data-page="webapplist"]');
|
refreshList();
|
||||||
if (page) {
|
|
||||||
page.dataset.loaded = '';
|
|
||||||
if (page.classList.contains('active')) window.loadWebAppList();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ data class WebApp(
|
|||||||
val icon: String,
|
val icon: String,
|
||||||
val url: String,
|
val url: String,
|
||||||
val offline: String? = null,
|
val offline: String? = null,
|
||||||
|
val offlineVersion: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
// 网络抽象接口:Task 10 的 CardRepository 也会复用
|
// 网络抽象接口:Task 10 的 CardRepository 也会复用
|
||||||
@@ -93,10 +94,12 @@ class WebAppRepository(
|
|||||||
val name = map["name"] as? String ?: return@mapNotNull null
|
val name = map["name"] as? String ?: return@mapNotNull null
|
||||||
val icon = map["icon"] as? String ?: return@mapNotNull null
|
val icon = map["icon"] as? String ?: return@mapNotNull null
|
||||||
val url = map["url"] as? String ?: return@mapNotNull null
|
val url = map["url"] as? String ?: return@mapNotNull null
|
||||||
// offline 是对象时取其 package 字段,否则 null(纯在线)
|
// offline 是对象时取其 package/version 字段,否则 null(纯在线)
|
||||||
// offline is an object -> take its package field, otherwise null (online-only)
|
// offline is an object -> take its package/version fields, otherwise null (online-only)
|
||||||
val offline = (map["offline"] as? Map<*, *>)?.get("package") as? String
|
val offlineMap = map["offline"] as? Map<*, *>
|
||||||
WebApp(id, name, icon, url, offline)
|
val offline = offlineMap?.get("package") as? String
|
||||||
|
val offlineVersion = offlineMap?.get("version") as? String
|
||||||
|
WebApp(id, name, icon, url, offline, offlineVersion)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,9 +30,23 @@ class WebAppStorage(private val context: Context) {
|
|||||||
fun localIndexUrl(id: String): String? =
|
fun localIndexUrl(id: String): String? =
|
||||||
if (hasLocalPackage(id)) "file://" + File(appDir(id), "index.html").absolutePath else null
|
if (hasLocalPackage(id)) "file://" + File(appDir(id), "index.html").absolutePath else null
|
||||||
|
|
||||||
// 下载并解压离线包,onProgress 回调 0-100
|
// 本地离线包版本号(解压目录里的 .version 文件),无则 null
|
||||||
// Download and extract the offline package, onProgress reports 0-100
|
// Local offline-package version (from the .version file), null when absent
|
||||||
fun downloadAndExtract(id: String, packageUrl: String, onProgress: (Int) -> Unit): Boolean {
|
fun getLocalVersion(id: String): String? {
|
||||||
|
val f = File(appDir(id), ".version")
|
||||||
|
return if (f.exists()) f.readText().trim().takeIf { it.isNotEmpty() } else null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除本地离线包
|
||||||
|
// Delete the local offline package
|
||||||
|
fun deleteLocalPackage(id: String) {
|
||||||
|
appDir(id).deleteRecursively()
|
||||||
|
Log.d(TAG, "deleteLocalPackage: id=$id")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下载并解压离线包(记录版本号),onProgress 回调 0-100
|
||||||
|
// Download and extract the offline package (record its version), onProgress 0-100
|
||||||
|
fun downloadAndExtract(id: String, packageUrl: String, version: String, onProgress: (Int) -> Unit): Boolean {
|
||||||
val tmp = File(context.cacheDir, "$id-offline.zip")
|
val tmp = File(context.cacheDir, "$id-offline.zip")
|
||||||
return try {
|
return try {
|
||||||
val request = Request.Builder().url(packageUrl).build()
|
val request = Request.Builder().url(packageUrl).build()
|
||||||
@@ -57,6 +71,7 @@ class WebAppStorage(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
val ok = extractZip(tmp, appDir(id))
|
val ok = extractZip(tmp, appDir(id))
|
||||||
|
if (ok) writeVersion(id, version)
|
||||||
tmp.delete()
|
tmp.delete()
|
||||||
ok
|
ok
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -66,14 +81,15 @@ class WebAppStorage(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 手动导入离线包:从 uri 复制 zip 并解压
|
// 手动导入离线包:从 uri 复制 zip 并解压(版本记为 manual)
|
||||||
// Manually import an offline package: copy the zip from uri and extract
|
// Manually import an offline package: copy the zip from uri and extract (version "manual")
|
||||||
fun importPackage(id: String, uri: Uri): Boolean {
|
fun importPackage(id: String, uri: Uri): Boolean {
|
||||||
val tmp = File(context.cacheDir, "$id-import.zip")
|
val tmp = File(context.cacheDir, "$id-import.zip")
|
||||||
return try {
|
return try {
|
||||||
val input = context.contentResolver.openInputStream(uri) ?: return false
|
val input = context.contentResolver.openInputStream(uri) ?: return false
|
||||||
FileOutputStream(tmp).use { output -> input.use { it.copyTo(output) } }
|
FileOutputStream(tmp).use { output -> input.use { it.copyTo(output) } }
|
||||||
val ok = extractZip(tmp, appDir(id))
|
val ok = extractZip(tmp, appDir(id))
|
||||||
|
if (ok) writeVersion(id, "manual")
|
||||||
tmp.delete()
|
tmp.delete()
|
||||||
ok
|
ok
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -83,6 +99,16 @@ class WebAppStorage(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 记录本地离线包版本号(.version 文件)
|
||||||
|
// Record the local offline-package version (a .version file)
|
||||||
|
private fun writeVersion(id: String, version: String) {
|
||||||
|
try {
|
||||||
|
File(appDir(id), ".version").writeText(version)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "writeVersion failed: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 解压 zip 到目标目录(先清空旧目录),校验 index.html 存在
|
// 解压 zip 到目标目录(先清空旧目录),校验 index.html 存在
|
||||||
// Extract the zip into the destination (clear it first), verify index.html exists
|
// Extract the zip into the destination (clear it first), verify index.html exists
|
||||||
private fun extractZip(zip: File, dest: File): Boolean {
|
private fun extractZip(zip: File, dest: File): Boolean {
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ class JsBridge(
|
|||||||
pushWebappProgress(id, 0)
|
pushWebappProgress(id, 0)
|
||||||
Thread {
|
Thread {
|
||||||
val fullUrl = resolveUrl(pkg)
|
val fullUrl = resolveUrl(pkg)
|
||||||
st.downloadAndExtract(id, fullUrl) { p -> pushWebappProgress(id, p) }
|
st.downloadAndExtract(id, fullUrl, app.offlineVersion ?: "") { p -> pushWebappProgress(id, p) }
|
||||||
val url = st.localIndexUrl(id) ?: app.url
|
val url = st.localIndexUrl(id) ?: app.url
|
||||||
openWebAppAt(container, host, id, url, app.name)
|
openWebAppAt(container, host, id, url, app.name)
|
||||||
pushWebappProgress(id, 100)
|
pushWebappProgress(id, 100)
|
||||||
@@ -185,6 +185,36 @@ class JsBridge(
|
|||||||
@android.webkit.JavascriptInterface
|
@android.webkit.JavascriptInterface
|
||||||
fun hasLocalPackage(id: String): Boolean = storage?.hasLocalPackage(id) ?: false
|
fun hasLocalPackage(id: String): Boolean = storage?.hasLocalPackage(id) ?: false
|
||||||
|
|
||||||
|
// 本地离线包版本号(无则空字符串)
|
||||||
|
// Local offline-package version (empty string when absent)
|
||||||
|
@android.webkit.JavascriptInterface
|
||||||
|
fun getLocalVersion(id: String): String = storage?.getLocalVersion(id) ?: ""
|
||||||
|
|
||||||
|
// 删除本地离线包
|
||||||
|
// Delete the local offline package
|
||||||
|
@android.webkit.JavascriptInterface
|
||||||
|
fun deleteLocalPackage(id: String) {
|
||||||
|
Log.d("HearthBridge", "deleteLocalPackage: id=$id")
|
||||||
|
storage?.deleteLocalPackage(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 强制更新离线包:删除本地后重新下载
|
||||||
|
// Force-update the offline package: delete local then re-download
|
||||||
|
@android.webkit.JavascriptInterface
|
||||||
|
fun updateOfflinePackage(id: String) {
|
||||||
|
Log.d("HearthBridge", "updateOfflinePackage: id=$id")
|
||||||
|
val st = storage ?: return
|
||||||
|
val app = manifest().firstOrNull { it.id == id } ?: return
|
||||||
|
val pkg = app.offline ?: return
|
||||||
|
st.deleteLocalPackage(id)
|
||||||
|
pushWebappProgress(id, 0)
|
||||||
|
Thread {
|
||||||
|
val fullUrl = resolveUrl(pkg)
|
||||||
|
st.downloadAndExtract(id, fullUrl, app.offlineVersion ?: "") { p -> pushWebappProgress(id, p) }
|
||||||
|
pushWebappProgress(id, 100)
|
||||||
|
}.start()
|
||||||
|
}
|
||||||
|
|
||||||
// 推送 webapp 加载/下载进度给 H5(0-100)
|
// 推送 webapp 加载/下载进度给 H5(0-100)
|
||||||
// Push webapp load/download progress to H5 (0-100)
|
// Push webapp load/download progress to H5 (0-100)
|
||||||
private fun pushWebappProgress(id: String, progress: Int) {
|
private fun pushWebappProgress(id: String, progress: Int) {
|
||||||
|
|||||||
Reference in New Issue
Block a user