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";
|
const checkScript = "/opt/spark-store/extras/check-is-installed";
|
||||||
let isInstalled = false;
|
let isInstalled = false;
|
||||||
|
|
||||||
|
// 首先尝试使用内置脚本
|
||||||
|
if (fs.existsSync(checkScript)) {
|
||||||
const child = spawn(checkScript, [pkgname], {
|
const child = spawn(checkScript, [pkgname], {
|
||||||
shell: false,
|
shell: false,
|
||||||
env: process.env,
|
env: process.env,
|
||||||
@@ -419,21 +421,33 @@ ipcMain.handle("check-installed", async (_event, pkgname: string) => {
|
|||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
child.on("error", (err) => {
|
child.on("error", (err) => {
|
||||||
logger.error(`check-installed 执行失败: ${err?.message || err}`);
|
logger.error(`check-installed 脚本执行失败: ${err?.message || err}`);
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
|
|
||||||
child.on("close", (code) => {
|
child.on("close", (code) => {
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
isInstalled = true;
|
isInstalled = true;
|
||||||
logger.info(`应用已安装: ${pkgname}`);
|
logger.info(`应用已安装 (脚本检测): ${pkgname}`);
|
||||||
} else {
|
|
||||||
logger.info(`应用未安装: ${pkgname} (exit ${code})`);
|
|
||||||
}
|
}
|
||||||
resolve();
|
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;
|
return isInstalled;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -456,7 +470,7 @@ ipcMain.on("remove-installed", async (_event, pkgname: string) => {
|
|||||||
}
|
}
|
||||||
const child = spawn(
|
const child = spawn(
|
||||||
execCommand,
|
execCommand,
|
||||||
[...execParams, "aptss", "remove", pkgname ],
|
[...execParams, "aptss", "remove", pkgname],
|
||||||
{
|
{
|
||||||
shell: true,
|
shell: true,
|
||||||
env: process.env,
|
env: process.env,
|
||||||
@@ -579,8 +593,8 @@ ipcMain.handle("launch-app", async (_event, pkgname: string) => {
|
|||||||
logger.warn("No pkgname provided for launch-app");
|
logger.warn("No pkgname provided for launch-app");
|
||||||
}
|
}
|
||||||
|
|
||||||
const execCommand = "/opt/spark-store/extras/host-spawn";
|
const execCommand = "/opt/spark-store/extras/app-launcher";
|
||||||
const execParams = ["/opt/spark-store/extras/app-launcher", "launch", pkgname];
|
const execParams = ["start", pkgname];
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`Launching app: ${pkgname} with command: ${execCommand} ${execParams.join(" ")}`,
|
`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,
|
baseURL: APM_STORE_BASE_URL,
|
||||||
timeout: 5000, // 增加到 5 秒,避免网络波动导致的超时
|
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()}`;
|
const cacheBuster = (url: string) => `${url}?cb=${Date.now()}`;
|
||||||
|
|
||||||
// 响应式状态
|
// 响应式状态
|
||||||
@@ -331,11 +349,7 @@ const loadScreenshots = (app: App) => {
|
|||||||
screenshots.value = [];
|
screenshots.value = [];
|
||||||
for (let i = 1; i <= 5; i++) {
|
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 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);
|
screenshots.value.push(screenshotUrl);
|
||||||
};
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -389,13 +403,38 @@ const loadHome = async () => {
|
|||||||
const r = await fetch(url);
|
const r = await fetch(url);
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
const appsJson = await r.json();
|
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 || "",
|
name: a.Name || a.name || a.Pkgname || a.PkgName || "",
|
||||||
pkgname: a.Pkgname || a.pkgname || "",
|
pkgname: a.Pkgname || a.pkgname || "",
|
||||||
category: a.Category || a.category || "unknown",
|
category: a.Category || a.category || "unknown",
|
||||||
more: a.More || a.more || "",
|
more: a.More || a.more || "",
|
||||||
version: a.Version || "",
|
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 });
|
homeLists.value.push({ title: item.name || "推荐", apps });
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -633,8 +672,8 @@ const onUninstallSuccess = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const installCompleteCallback = () => {
|
const installCompleteCallback = (pkgname?: string) => {
|
||||||
if (currentApp.value) {
|
if (currentApp.value && (!pkgname || currentApp.value.pkgname === pkgname)) {
|
||||||
checkAppInstalled(currentApp.value);
|
checkAppInstalled(currentApp.value);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -739,23 +778,21 @@ const loadCategories = async () => {
|
|||||||
|
|
||||||
const loadApps = async (onFirstBatch?: () => void) => {
|
const loadApps = async (onFirstBatch?: () => void) => {
|
||||||
try {
|
try {
|
||||||
logger.info("开始加载应用数据(并发分批)...");
|
logger.info("开始加载应用数据(全并发带重试)...");
|
||||||
|
|
||||||
const categoriesList = Object.keys(categories.value || {});
|
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(
|
await Promise.all(
|
||||||
batch.map(async (category) => {
|
categoriesList.map(async (category) => {
|
||||||
try {
|
try {
|
||||||
logger.info(`加载分类: ${category}`);
|
logger.info(`加载分类: ${category}`);
|
||||||
const response = await axiosInstance.get<AppJson[]>(
|
const categoryApps = await fetchWithRetry<AppJson[]>(
|
||||||
cacheBuster(`/${window.apm_store.arch}/${category}/applist.json`),
|
cacheBuster(`/${window.apm_store.arch}/${category}/applist.json`),
|
||||||
);
|
);
|
||||||
const categoryApps = response.status === 200 ? response.data : [];
|
|
||||||
categoryApps.forEach((appJson) => {
|
const normalizedApps = (categoryApps || []).map((appJson) => ({
|
||||||
const normalizedApp: App = {
|
|
||||||
name: appJson.Name,
|
name: appJson.Name,
|
||||||
pkgname: appJson.Pkgname,
|
pkgname: appJson.Pkgname,
|
||||||
version: appJson.Version,
|
version: appJson.Version,
|
||||||
@@ -774,21 +811,29 @@ const loadApps = async (onFirstBatch?: () => void) => {
|
|||||||
: appJson.img_urls,
|
: appJson.img_urls,
|
||||||
icons: appJson.icons,
|
icons: appJson.icons,
|
||||||
category: category,
|
category: category,
|
||||||
currentStatus: "not-installed",
|
currentStatus: "not-installed" as const,
|
||||||
};
|
}));
|
||||||
apps.value.push(normalizedApp);
|
|
||||||
});
|
// 增量式更新,让用户尽快看到部分数据
|
||||||
|
apps.value.push(...normalizedApps);
|
||||||
|
|
||||||
|
// 只要有一个分类加载成功,就可以考虑关闭整体 loading(如果是首批逻辑)
|
||||||
|
if (!firstBatchCallDone && typeof onFirstBatch === "function") {
|
||||||
|
firstBatchCallDone = true;
|
||||||
|
onFirstBatch();
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn(`加载分类 ${category} 失败: ${error}`);
|
logger.warn(`加载分类 ${category} 最终失败: ${error}`);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// 首批完成回调(用于隐藏首屏 loading)
|
// 确保即使全部失败也结束 loading
|
||||||
if (i === 0 && typeof onFirstBatch === "function") onFirstBatch();
|
if (!firstBatchCallDone && typeof onFirstBatch === "function") {
|
||||||
|
onFirstBatch();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`加载应用数据失败: ${error}`);
|
logger.error(`加载应用数据流程异常: ${error}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -805,14 +850,18 @@ onMounted(async () => {
|
|||||||
initTheme();
|
initTheme();
|
||||||
|
|
||||||
await loadCategories();
|
await loadCategories();
|
||||||
// 默认加载主页数据
|
|
||||||
await loadHome();
|
// 分类目录加载后,并行加载主页数据和所有应用列表
|
||||||
// 先显示 loading,并异步开始分批加载应用列表。
|
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
await Promise.all([
|
||||||
|
loadHome(),
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
loadApps(() => {
|
loadApps(() => {
|
||||||
// 当第一批分类加载完成后,隐藏首屏 loading
|
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
|
resolve();
|
||||||
});
|
});
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
// 设置键盘导航
|
// 设置键盘导航
|
||||||
document.addEventListener("keydown", (e) => {
|
document.addEventListener("keydown", (e) => {
|
||||||
|
|||||||
@@ -25,7 +25,10 @@
|
|||||||
v-if="app"
|
v-if="app"
|
||||||
:src="iconPath"
|
:src="iconPath"
|
||||||
alt="icon"
|
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>
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
@@ -108,7 +111,8 @@
|
|||||||
:key="index"
|
:key="index"
|
||||||
:src="screen"
|
:src="screen"
|
||||||
alt="screenshot"
|
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)"
|
@click="openPreview(index)"
|
||||||
@error="hideImage"
|
@error="hideImage"
|
||||||
/>
|
/>
|
||||||
@@ -229,6 +233,15 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const appPkgname = computed(() => props.app?.pkgname);
|
const appPkgname = computed(() => props.app?.pkgname);
|
||||||
|
|
||||||
|
const isIconLoaded = ref(false);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.app,
|
||||||
|
() => {
|
||||||
|
isIconLoaded.value = false;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const activeDownload = computed(() => {
|
const activeDownload = computed(() => {
|
||||||
return downloads.value.find((d) => d.pkgname === props.app?.pkgname);
|
return downloads.value.find((d) => d.pkgname === props.app?.pkgname);
|
||||||
});
|
});
|
||||||
@@ -236,8 +249,10 @@ const activeDownload = computed(() => {
|
|||||||
const { installFeedback } = useInstallFeedback(appPkgname);
|
const { installFeedback } = useInstallFeedback(appPkgname);
|
||||||
const { isCompleted } = useDownloadItemStatus(appPkgname);
|
const { isCompleted } = useDownloadItemStatus(appPkgname);
|
||||||
const installBtnText = computed(() => {
|
const installBtnText = computed(() => {
|
||||||
|
if (props.isinstalled) {
|
||||||
|
return "已安装";
|
||||||
|
}
|
||||||
if (isCompleted.value) {
|
if (isCompleted.value) {
|
||||||
// TODO: 似乎有一个时间差,安装好了之后并不是立马就可以从已安装列表看见
|
|
||||||
return "已安装";
|
return "已安装";
|
||||||
}
|
}
|
||||||
if (installFeedback.value) {
|
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"
|
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"
|
: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-base font-semibold text-slate-900">{{ link.name }}</div>
|
||||||
<div class="text-sm text-slate-500">{{ link.more }}</div>
|
<div class="text-sm text-slate-500">{{ link.more }}</div>
|
||||||
</a>
|
</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>();
|
const statusById = new Map<number, DownloadItemStatus>();
|
||||||
|
|
||||||
for (const item of downloads.value) {
|
for (const item of downloads.value) {
|
||||||
@@ -25,7 +25,7 @@ export function watchDownloadsChange(cb: () => void) {
|
|||||||
for (const item of list) {
|
for (const item of list) {
|
||||||
const prevStatus = statusById.get(item.id);
|
const prevStatus = statusById.get(item.id);
|
||||||
if (item.status === "completed" && prevStatus !== "completed") {
|
if (item.status === "completed" && prevStatus !== "completed") {
|
||||||
cb();
|
cb(item.pkgname);
|
||||||
}
|
}
|
||||||
statusById.set(item.id, item.status);
|
statusById.set(item.id, item.status);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user