Merge pull request #6 from vmomenv/momen-dev

Momen dev
This commit is contained in:
shenmo
2026-03-09 11:32:40 +08:00
committed by GitHub
5 changed files with 178 additions and 96 deletions

View File

@@ -412,6 +412,8 @@ ipcMain.handle("check-installed", async (_event, pkgname: string) => {
const checkScript = "/opt/spark-store/extras/check-is-installed";
let isInstalled = false;
// 首先尝试使用内置脚本
if (fs.existsSync(checkScript)) {
const child = spawn(checkScript, [pkgname], {
shell: false,
env: process.env,
@@ -419,21 +421,33 @@ ipcMain.handle("check-installed", async (_event, pkgname: string) => {
await new Promise<void>((resolve) => {
child.on("error", (err) => {
logger.error(`check-installed 执行失败: ${err?.message || err}`);
logger.error(`check-installed 脚本执行失败: ${err?.message || err}`);
resolve();
});
child.on("close", (code) => {
if (code === 0) {
isInstalled = true;
logger.info(`应用已安装: ${pkgname}`);
} else {
logger.info(`应用未安装: ${pkgname} (exit ${code})`);
logger.info(`应用已安装 (脚本检测): ${pkgname}`);
}
resolve();
});
});
if (isInstalled) return true;
}
// 如果脚本不存在或检测不到,使用 dpkg-query 作为后备
logger.info(`尝试使用 dpkg-query 检测: ${pkgname}`);
const { code } = await runCommandCapture("dpkg-query", ["-W", "-f='${Status}'", pkgname]);
if (code === 0) {
isInstalled = true;
logger.info(`应用已安装 (dpkg-query 检测): ${pkgname}`);
} else {
logger.info(`应用未安装: ${pkgname}`);
}
return isInstalled;
});
@@ -579,8 +593,8 @@ ipcMain.handle("launch-app", async (_event, pkgname: string) => {
logger.warn("No pkgname provided for launch-app");
}
const execCommand = "/opt/spark-store/extras/host-spawn";
const execParams = ["/opt/spark-store/extras/app-launcher", "launch", pkgname];
const execCommand = "/opt/spark-store/extras/app-launcher";
const execParams = ["start", pkgname];
logger.info(
`Launching app: ${pkgname} with command: ${execCommand} ${execParams.join(" ")}`,

View File

@@ -179,6 +179,24 @@ const axiosInstance = axios.create({
baseURL: APM_STORE_BASE_URL,
timeout: 5000, // 增加到 5 秒,避免网络波动导致的超时
});
const fetchWithRetry = async <T>(
url: string,
retries = 3,
delay = 1000,
): Promise<T> => {
try {
const response = await axiosInstance.get<T>(url);
return response.data;
} catch (error) {
if (retries > 0) {
await new Promise((resolve) => setTimeout(resolve, delay));
return fetchWithRetry(url, retries - 1, delay * 2);
}
throw error;
}
};
const cacheBuster = (url: string) => `${url}?cb=${Date.now()}`;
// 响应式状态
@@ -331,11 +349,7 @@ const loadScreenshots = (app: App) => {
screenshots.value = [];
for (let i = 1; i <= 5; i++) {
const screenshotUrl = `${APM_STORE_BASE_URL}/${window.apm_store.arch}/${app.category}/${app.pkgname}/screen_${i}.png`;
const img = new Image();
img.src = screenshotUrl;
img.onload = () => {
screenshots.value.push(screenshotUrl);
};
}
};
@@ -389,13 +403,38 @@ const loadHome = async () => {
const r = await fetch(url);
if (r.ok) {
const appsJson = await r.json();
const apps = (appsJson || []).map((a: any) => ({
const rawApps = appsJson || [];
const apps = await Promise.all(
rawApps.map(async (a: any) => {
const baseApp = {
name: a.Name || a.name || a.Pkgname || a.PkgName || "",
pkgname: a.Pkgname || a.pkgname || "",
category: a.Category || a.category || "unknown",
more: a.More || a.more || "",
version: a.Version || "",
}));
filename: a.Filename || a.filename || "",
};
// 根据官网的要求读取Category和Pkgname拼接出 源地址/架构/Category/Pkgname/app.json来获取对应的真实json
try {
const realAppUrl = `${APM_STORE_BASE_URL}/${window.apm_store.arch}/${baseApp.category}/${baseApp.pkgname}/app.json`;
const realRes = await fetch(realAppUrl);
if (realRes.ok) {
const realApp = await realRes.json();
// 用真实json的filename字段和More字段来增补和覆盖当前的json
if (realApp.Filename) baseApp.filename = realApp.Filename;
if (realApp.More) baseApp.more = realApp.More;
if (realApp.Name) baseApp.name = realApp.Name;
}
} catch (e) {
console.warn(
`Failed to fetch real app.json for ${baseApp.pkgname}`,
e,
);
}
return baseApp;
}),
);
homeLists.value.push({ title: item.name || "推荐", apps });
}
} catch (e) {
@@ -633,8 +672,8 @@ const onUninstallSuccess = () => {
}
};
const installCompleteCallback = () => {
if (currentApp.value) {
const installCompleteCallback = (pkgname?: string) => {
if (currentApp.value && (!pkgname || currentApp.value.pkgname === pkgname)) {
checkAppInstalled(currentApp.value);
}
};
@@ -739,23 +778,21 @@ const loadCategories = async () => {
const loadApps = async (onFirstBatch?: () => void) => {
try {
logger.info("开始加载应用数据(并发分批...");
logger.info("开始加载应用数据(并发带重试...");
const categoriesList = Object.keys(categories.value || {});
const concurrency = 4; // 同时并发请求数量,可根据网络条件调整
let firstBatchCallDone = false;
for (let i = 0; i < categoriesList.length; i += concurrency) {
const batch = categoriesList.slice(i, i + concurrency);
// 并发加载所有分类,每个分类自带重试机制
await Promise.all(
batch.map(async (category) => {
categoriesList.map(async (category) => {
try {
logger.info(`加载分类: ${category}`);
const response = await axiosInstance.get<AppJson[]>(
const categoryApps = await fetchWithRetry<AppJson[]>(
cacheBuster(`/${window.apm_store.arch}/${category}/applist.json`),
);
const categoryApps = response.status === 200 ? response.data : [];
categoryApps.forEach((appJson) => {
const normalizedApp: App = {
const normalizedApps = (categoryApps || []).map((appJson) => ({
name: appJson.Name,
pkgname: appJson.Pkgname,
version: appJson.Version,
@@ -774,21 +811,29 @@ const loadApps = async (onFirstBatch?: () => void) => {
: appJson.img_urls,
icons: appJson.icons,
category: category,
currentStatus: "not-installed",
};
apps.value.push(normalizedApp);
});
currentStatus: "not-installed" as const,
}));
// 增量式更新,让用户尽快看到部分数据
apps.value.push(...normalizedApps);
// 只要有一个分类加载成功,就可以考虑关闭整体 loading如果是首批逻辑
if (!firstBatchCallDone && typeof onFirstBatch === "function") {
firstBatchCallDone = true;
onFirstBatch();
}
} catch (error) {
logger.warn(`加载分类 ${category} 失败: ${error}`);
logger.warn(`加载分类 ${category} 最终失败: ${error}`);
}
}),
);
// 首批完成回调(用于隐藏首屏 loading
if (i === 0 && typeof onFirstBatch === "function") onFirstBatch();
// 确保即使全部失败也结束 loading
if (!firstBatchCallDone && typeof onFirstBatch === "function") {
onFirstBatch();
}
} catch (error) {
logger.error(`加载应用数据失败: ${error}`);
logger.error(`加载应用数据流程异常: ${error}`);
}
};
@@ -805,14 +850,18 @@ onMounted(async () => {
initTheme();
await loadCategories();
// 默认加载主页数据
await loadHome();
// 先显示 loading并异步开始分批加载应用列表。
// 分类目录加载后,并行加载主页数据和所有应用列表
loading.value = true;
await Promise.all([
loadHome(),
new Promise<void>((resolve) => {
loadApps(() => {
// 当第一批分类加载完成后,隐藏首屏 loading
loading.value = false;
resolve();
});
}),
]);
// 设置键盘导航
document.addEventListener("keydown", (e) => {

View File

@@ -25,7 +25,10 @@
v-if="app"
:src="iconPath"
alt="icon"
class="h-full w-full object-cover"
class="h-full w-full object-cover transition-opacity duration-300"
:class="isIconLoaded ? 'opacity-100' : 'opacity-0'"
loading="lazy"
@load="isIconLoaded = true"
/>
</div>
<div class="space-y-1">
@@ -108,7 +111,8 @@
:key="index"
:src="screen"
alt="screenshot"
class="h-40 w-full cursor-pointer rounded-2xl border border-slate-200/60 object-cover shadow-sm transition hover:-translate-y-1 hover:shadow-lg dark:border-slate-800/60"
class="h-40 w-full cursor-pointer rounded-2xl border border-slate-200/60 object-cover shadow-sm transition-all duration-300 hover:-translate-y-1 hover:shadow-lg dark:border-slate-800/60"
loading="lazy"
@click="openPreview(index)"
@error="hideImage"
/>
@@ -229,6 +233,15 @@ const emit = defineEmits<{
const appPkgname = computed(() => props.app?.pkgname);
const isIconLoaded = ref(false);
watch(
() => props.app,
() => {
isIconLoaded.value = false;
},
);
const activeDownload = computed(() => {
return downloads.value.find((d) => d.pkgname === props.app?.pkgname);
});
@@ -236,8 +249,10 @@ const activeDownload = computed(() => {
const { installFeedback } = useInstallFeedback(appPkgname);
const { isCompleted } = useDownloadItemStatus(appPkgname);
const installBtnText = computed(() => {
if (props.isinstalled) {
return "已安装";
}
if (isCompleted.value) {
// TODO: 似乎有一个时间差,安装好了之后并不是立马就可以从已安装列表看见
return "已安装";
}
if (installFeedback.value) {

View File

@@ -12,7 +12,11 @@
class="flex flex-col items-start gap-2 rounded-2xl border border-slate-200/70 bg-white/90 p-4 shadow-sm hover:shadow-lg transition"
:title="link.more"
>
<img :src="computedImgUrl(link.imgUrl)" class="h-20 w-full object-contain" />
<img
:src="computedImgUrl(link.imgUrl)"
class="h-20 w-full object-contain"
loading="lazy"
/>
<div class="text-base font-semibold text-slate-900">{{ link.name }}</div>
<div class="text-sm text-slate-500">{{ link.more }}</div>
</a>

View File

@@ -12,7 +12,7 @@ export function removeDownloadItem(pkgname: string) {
}
}
export function watchDownloadsChange(cb: () => void) {
export function watchDownloadsChange(cb: (pkgname: string) => void) {
const statusById = new Map<number, DownloadItemStatus>();
for (const item of downloads.value) {
@@ -25,7 +25,7 @@ export function watchDownloadsChange(cb: () => void) {
for (const item of list) {
const prevStatus = statusById.get(item.id);
if (item.status === "completed" && prevStatus !== "completed") {
cb();
cb(item.pkgname);
}
statusById.set(item.id, item.status);
}