mirror of
https://gitee.com/spark-store-project/spark-store
synced 2026-04-26 09:20:18 +08:00
@@ -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(" ")}`,
|
||||
|
||||
107
src/App.vue
107
src/App.vue
@@ -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) => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user