mirror of
https://gitee.com/spark-store-project/spark-store
synced 2026-08-09 22:04:06 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77b4244f1d | ||
|
|
c1b89cab1a | ||
|
|
17cbb7cff2 | ||
|
|
bcf3252e4a | ||
|
|
910aec6cce | ||
|
|
3d28010797 | ||
|
|
df8c2d7eaa | ||
|
|
e857ce67b3 | ||
|
|
98296fa8a3 | ||
|
|
96541cc13e | ||
|
|
b84f82e7bb | ||
|
|
e7476f4bc3 | ||
|
|
0a6fcd95c7 | ||
|
|
99c6048295 | ||
|
|
3b7f14862d | ||
|
|
367828f1f4 | ||
|
|
70250acc76 | ||
|
|
3157714776 | ||
|
|
bfd7626b8f | ||
|
|
5d512c4e86 | ||
|
|
9323c368d6 | ||
|
|
1f9971f7aa | ||
|
|
d67f7b3666 | ||
|
|
c2ba273ca2 | ||
|
|
361dad1a64 | ||
|
|
5730c0bfea |
+10
@@ -55,3 +55,13 @@ test-results.json
|
||||
|
||||
# VSCode CMake Extension
|
||||
build/*
|
||||
|
||||
# Debian build intermediates
|
||||
debian/*.log
|
||||
debian/*.substvars
|
||||
debian/*.debhelper
|
||||
debian/debhelper-build-stamp
|
||||
debian/files
|
||||
debian/tmp/
|
||||
debian/.debhelper/
|
||||
debian/spark-store/
|
||||
|
||||
Vendored
+1
@@ -30,6 +30,7 @@
|
||||
// },
|
||||
"runtimeArgs": [
|
||||
"--remote-debugging-port=9229",
|
||||
"--no-sandbox",
|
||||
"."
|
||||
],
|
||||
"envFile": "${workspaceFolder}/.vscode/.debug.env",
|
||||
|
||||
@@ -1,3 +1,22 @@
|
||||
## [未发布 / Unreleased] (Erotica 分支,基于 1e60a4c2 之后)
|
||||
|
||||
本批改动汇总(自提交 `1e60a4c2` 起):
|
||||
|
||||
1. **已安装应用新增搜索框 + 窗口尺寸/位置持久化**:补齐已安装面板的应用搜索框;主窗口大小、位置、最大化状态写入本地 `window-state.json` 持久化,并限制最小尺寸 800×500。
|
||||
2. **已安装列表健壮性加固**:修复异步竞态、错误 UX、加载状态处理与刷新防抖(多轮 AI 审查改进),并清理对应代码缩进与对齐。
|
||||
3. **各页面滚动条贴边修复**:避免滚动栏被窗口圆角裁切。
|
||||
4. **投稿应用窗口圆角虚影修复**:消除投稿弹窗圆角处的方角残影。
|
||||
5. **已安装应用来源筛选与排序优化**:将头部 APM / Spark / 总数三段式统计徽章改为可点击,点击后按来源筛选(默认显示全部);列表默认 APM 应用置顶;新增"暂无已安装的 APM/Spark 应用"空状态提示,避免筛选无结果时出现空引号。
|
||||
6. **AI 代码审查安全加固**:
|
||||
- `launch-app` IPC 移除 `any`,新增包名正则校验(`/^[a-zA-Z0-9._+-]+$/`),拦截非法输入防止命令注入。
|
||||
- `getIconUrl` 本地图标路径增加白名单目录校验与路径遍历(`..`)防护。
|
||||
- `fetchWithRetry` 仅对网络错误 / 5xx / 超时重试,4xx 快速失败。
|
||||
- `AppGrid` 普通网格 `v-for` 的 `:key` 由 `index` 改为 `app.pkgname`。
|
||||
- `loong64` 架构下尊重 `--no-spark` 启动参数,不再硬编码覆盖。
|
||||
- `openDownloadedApp` 的 IPC 调用补充 `.catch` 错误处理。
|
||||
|
||||
相关提交:`1d9d25d6`、`c75938aa`、`39a358bb`、`51473cac`、`cf0e72ff`、`b0d60737`、`d3705ed4`。
|
||||
|
||||
## [1.1.1](https://github.com/elysia-best/apm-app-store/compare/v1.1.0...v1.1.1) (2026-02-17)
|
||||
|
||||
|
||||
|
||||
@@ -47,4 +47,4 @@ install:
|
||||
cp -rv pkg/usr/share/dsg/ $(DESTDIR)/usr/share/
|
||||
cp -rv pkg/usr/share/bash-completion/ $(DESTDIR)/usr/share/
|
||||
cp -rv tool/spark-store.asc $(DESTDIR)/opt/durapps/spark-store/bin/
|
||||
ln -s ../../../spark-store/extras/spark-store $(DESTDIR)/opt/durapps/spark-store/bin/spark-store
|
||||
# ln -s ../../../spark-store/extras/spark-store $(DESTDIR)/opt/durapps/spark-store/bin/spark-store
|
||||
|
||||
Vendored
+1
-1
@@ -1,4 +1,4 @@
|
||||
spark-store (5.2.1-0) UNRELEASED; urgency=medium
|
||||
spark-store (5.2.1.0) UNRELEASED; urgency=medium
|
||||
|
||||
* Initial release. (Closes: #nnnn) <nnnn is the bug number of your ITP>
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ type InstallTask = {
|
||||
metalinkUrl?: string;
|
||||
filename?: string;
|
||||
origin: "spark" | "apm";
|
||||
upgradeOnly?: boolean;
|
||||
cancelled?: boolean;
|
||||
phase: "queued-download" | "downloading" | "queued-install" | "installing";
|
||||
};
|
||||
@@ -268,7 +269,8 @@ ipcMain.on("queue-install", async (event, download_json) => {
|
||||
typeof download_json === "string"
|
||||
? JSON.parse(download_json)
|
||||
: download_json;
|
||||
const { id, pkgname, metalinkUrl, filename, origin } = download || {};
|
||||
const { id, pkgname, metalinkUrl, filename, origin, upgradeOnly } =
|
||||
download || {};
|
||||
|
||||
if (!id || !pkgname) {
|
||||
logger.warn("passed arguments missing id or pkgname");
|
||||
@@ -376,6 +378,7 @@ ipcMain.on("queue-install", async (event, download_json) => {
|
||||
metalinkUrl,
|
||||
filename,
|
||||
origin: origin || "apm",
|
||||
upgradeOnly: Boolean(upgradeOnly),
|
||||
phase: metalinkUrl ? "queued-download" : "queued-install",
|
||||
};
|
||||
tasks.set(id, task);
|
||||
@@ -769,9 +772,9 @@ async function runInstallPhase(task: InstallTask) {
|
||||
if (success) {
|
||||
logger.info(msgObj);
|
||||
|
||||
// 安装成功后,如果是APM安装的,就调用createApmDesktopShortcut
|
||||
// 这个函数负责处理桌面快捷方式,它自己会读取设置并且决定要不要创建
|
||||
if (task.origin === "apm" && task.pkgname) {
|
||||
// 安装成功后,如果是APM新安装任务,就调用createApmDesktopShortcut
|
||||
// 升级任务不创建桌面快捷方式;这个函数自己会读取设置并决定要不要创建
|
||||
if (task.origin === "apm" && task.pkgname && !task.upgradeOnly) {
|
||||
try {
|
||||
await createApmDesktopShortcut(task.pkgname, sendLog);
|
||||
} catch (err) {
|
||||
@@ -994,8 +997,16 @@ ipcMain.handle(
|
||||
}> = [];
|
||||
|
||||
if (origin === "spark") {
|
||||
// 如果提供了包名列表,只检查这些包的安装状态(优化版)
|
||||
if (pkgnameList && pkgnameList.length > 0) {
|
||||
// 显式传入了包名列表(可能是空数组):只检查这些包的安装状态(优化版)
|
||||
if (Array.isArray(pkgnameList)) {
|
||||
if (pkgnameList.length === 0) {
|
||||
// 商店目录尚未加载或该来源没有任何可枚举的包时,
|
||||
// 直接返回空列表,避免退化为“全量扫描整个系统”后再被渲染端全部跳过,
|
||||
// 否则会误报“已安装应用为空”。
|
||||
logger.info("Spark 包名列表为空,跳过已安装检查");
|
||||
return { success: true, apps: [] };
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`使用优化模式检查 ${pkgnameList.length} 个 Spark 包的安装状态`,
|
||||
);
|
||||
@@ -1037,7 +1048,7 @@ ipcMain.handle(
|
||||
return { success: true, apps: installedApps };
|
||||
}
|
||||
|
||||
// 回退到全量扫描模式(未提供包名列表时)
|
||||
// 回退到全量扫描模式(仅当调用方未传入 pkgnameList 时,例如旧版直接调用)
|
||||
logger.info("使用全量扫描模式获取所有 Spark 已安装包");
|
||||
const { code, stdout } = await runCommandCapture("dpkg-query", [
|
||||
"-W",
|
||||
@@ -1271,13 +1282,31 @@ ipcMain.handle("uninstall-installed", async (_event, payload: any) => {
|
||||
};
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
ipcMain.handle("launch-app", async (_event, payload: any) => {
|
||||
interface LaunchAppPayload {
|
||||
pkgname: string;
|
||||
origin?: "spark" | "apm";
|
||||
}
|
||||
|
||||
// 合法包名字符(Debian 包名规范 + Spark 应用包名常见字符),
|
||||
// 用于拦截包含特殊字符的非法输入,避免命令注入。
|
||||
const PKGNAME_PATTERN = /^[a-zA-Z0-9._+-]+$/;
|
||||
|
||||
ipcMain.handle(
|
||||
"launch-app",
|
||||
async (
|
||||
_event,
|
||||
payload: LaunchAppPayload,
|
||||
): Promise<{ success: boolean; message?: string }> => {
|
||||
const pkgname = typeof payload === "string" ? payload : payload.pkgname;
|
||||
const origin = typeof payload === "string" ? "spark" : payload.origin;
|
||||
|
||||
if (!pkgname) {
|
||||
logger.warn("No pkgname provided for launch-app");
|
||||
if (
|
||||
!pkgname ||
|
||||
typeof pkgname !== "string" ||
|
||||
!PKGNAME_PATTERN.test(pkgname)
|
||||
) {
|
||||
logger.warn(`Invalid pkgname provided for launch-app: ${pkgname}`);
|
||||
return { success: false, message: "Invalid package name" };
|
||||
}
|
||||
|
||||
let execCommand = "/opt/spark-store/extras/app-launcher";
|
||||
@@ -1298,4 +1327,7 @@ ipcMain.handle("launch-app", async (_event, payload: any) => {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
}).unref();
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
);
|
||||
|
||||
@@ -448,6 +448,7 @@ export function registerSubmitterHandlers(
|
||||
width: 800,
|
||||
height: 900,
|
||||
frame: false,
|
||||
transparent: true,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
icon: path.join(process.env.VITE_PUBLIC!, "favicon.ico"),
|
||||
@@ -972,6 +973,24 @@ export function registerSubmitterHandlers(
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("get-git-name", async () => {
|
||||
try {
|
||||
const { exec } = await import("node:child_process");
|
||||
const util = await import("util");
|
||||
const execAsync = util.promisify(exec);
|
||||
const { stdout } = await execAsync("git config user.name");
|
||||
const name = stdout.trim();
|
||||
logger.info({ name }, "[Submitter] Git name retrieved");
|
||||
return { success: true, data: name || "" };
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
"[Submitter] Failed to get git name, not a git repo or git not installed",
|
||||
);
|
||||
return { success: false, data: "" };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("get-tags-list", async () => {
|
||||
try {
|
||||
const apiUrl = "https://upload.deepinos.org.cn/api/index/get_tags_list";
|
||||
@@ -1402,8 +1421,9 @@ export function registerSubmitterHandlers(
|
||||
// arch 由前端解析 deb 时获取并传入,无需再次调用 dpkg-deb
|
||||
const debArch = String(dataObj.arch || "amd64");
|
||||
const pkgVersion = String(dataObj.version || "0.0.0");
|
||||
const debPkgName = String(dataObj.pkgname || "unknown");
|
||||
const formFileName = `${debPkgName}_${pkgVersion}_${debArch}.deb`;
|
||||
const debPkgName = String(dataObj.pkgname || "unknown").toLowerCase();
|
||||
const formFileName =
|
||||
`${debPkgName}_${pkgVersion}_${debArch}.deb`.toLowerCase();
|
||||
logger.info(
|
||||
{ formFileName, debArch },
|
||||
"[Submitter] Constructed file_name",
|
||||
@@ -1671,7 +1691,7 @@ export function registerSubmitterHandlers(
|
||||
}
|
||||
|
||||
const dataObj = formData as Record<string, unknown>;
|
||||
const pkgname = String(dataObj.pkgname || "");
|
||||
const pkgname = String(dataObj.pkgname || "").toLowerCase();
|
||||
const packageName = String(dataObj.name || "");
|
||||
const packageVersion = String(dataObj.version || "");
|
||||
const packageCategory = String(dataObj.category || "");
|
||||
@@ -1862,7 +1882,8 @@ export function registerSubmitterHandlers(
|
||||
"[Submitter] Deb metadata parsed",
|
||||
);
|
||||
|
||||
const packDebFileName = `${debPkgName}_${debVersion}_${debArch}.deb`;
|
||||
const packDebFileName =
|
||||
`${debPkgName}_${debVersion}_${debArch}.deb`.toLowerCase();
|
||||
logger.info(
|
||||
{
|
||||
from: debFilePath,
|
||||
@@ -1913,7 +1934,7 @@ export function registerSubmitterHandlers(
|
||||
sendPackageProgress("tar", 85, "正在打包 tar.gz...");
|
||||
logger.info("[Submitter] Starting tar...");
|
||||
|
||||
const tarFileName = `${pkgname}-${storeArch}.tar.gz`;
|
||||
const tarFileName = `${pkgname.toLowerCase()}-${storeArch.toLowerCase()}.tar.gz`;
|
||||
const tarOutputPath = path.join(baseTempDir, tarFileName);
|
||||
try {
|
||||
await execAsync(
|
||||
@@ -1938,7 +1959,9 @@ export function registerSubmitterHandlers(
|
||||
sendPackageProgress("done", 100, "打包完成!");
|
||||
|
||||
logger.info({ baseTempDir }, "[Submitter] Opening folder");
|
||||
await shell.openPath(baseTempDir);
|
||||
shell.openPath(baseTempDir).catch((err) => {
|
||||
logger.warn({ err }, "[Submitter] Failed to open folder");
|
||||
});
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
logger.info(
|
||||
|
||||
+132
-3
@@ -7,6 +7,7 @@ import {
|
||||
shell,
|
||||
Tray,
|
||||
nativeTheme,
|
||||
screen,
|
||||
session,
|
||||
} from "electron";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -72,6 +73,13 @@ if (!app.requestSingleInstanceLock()) {
|
||||
import "./backend/install-manager.js";
|
||||
import "./handle-url-scheme.js";
|
||||
|
||||
// 关闭 Linux 的覆盖式(overlay)滚动条,强制使用经典滚动条,
|
||||
// 否则 GTK overlay 滚动条会忽略渲染进程的 ::-webkit-scrollbar 颜色,
|
||||
// 导致暗色模式下滚动条始终是原生灰色。必须在 app ready 前设置。
|
||||
if (process.platform === "linux") {
|
||||
app.commandLine.appendSwitch("disable-features", "OverlayScrollbar");
|
||||
}
|
||||
|
||||
const logger = pino({ name: "index.ts" });
|
||||
const FLARUM_TOKEN_URL = "https://bbs.spark-app.store/api/token";
|
||||
|
||||
@@ -129,7 +137,9 @@ logger.info("User Agent: " + getUserAgent());
|
||||
/** 根据启动参数 --no-apm / --no-spark 决定只展示的来源 */
|
||||
function getStoreFilterFromArgv(): "spark" | "apm" | "both" {
|
||||
if (process.arch === "loong64") {
|
||||
// Currently loong64 only have spark support
|
||||
// Currently loong64 only have spark support,
|
||||
// 但用户显式传入 --no-spark 时应允许回退到 apm
|
||||
if (process.argv.includes("--no-spark")) return "apm";
|
||||
return "spark";
|
||||
} else {
|
||||
const argv = process.argv;
|
||||
@@ -146,6 +156,12 @@ ipcMain.handle("get-store-filter", (): "spark" | "apm" | "both" =>
|
||||
getStoreFilterFromArgv(),
|
||||
);
|
||||
|
||||
// 渲染端在窗口尺寸变化时(包括无边框窗口鼠标拉边角)经此保存当前窗口尺寸
|
||||
ipcMain.handle("save-window-bounds", (): boolean => {
|
||||
if (win && !win.isDestroyed()) scheduleSaveBounds(win);
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle("get-app-version", (): string => getAppVersion());
|
||||
ipcMain.handle("get-system-info", (): { distro: string } => getSystemInfo());
|
||||
|
||||
@@ -270,11 +286,103 @@ const showAndFocusMainWindow = (): void => {
|
||||
win.focus();
|
||||
};
|
||||
|
||||
// 窗口尺寸持久化:保存/恢复上一次调整后的窗口大小,避免每次打开都使用默认尺寸
|
||||
const DEFAULT_WINDOW_SIZE = { width: 1366, height: 768 };
|
||||
const MIN_WINDOW_SIZE = { width: 800, height: 500 };
|
||||
|
||||
interface WindowState {
|
||||
width?: number;
|
||||
height?: number;
|
||||
x?: number;
|
||||
y?: number;
|
||||
maximized?: boolean;
|
||||
}
|
||||
|
||||
function getWindowStatePath(): string {
|
||||
// 延迟到调用时再取 userData,避免在 app ready 之前调用 app.getPath 出错
|
||||
return path.join(app.getPath("userData"), "window-state.json");
|
||||
}
|
||||
|
||||
// 校验保存的窗口位置是否至少部分落在某个显示器可见区域内,避免窗口跑到屏幕外
|
||||
function isVisible(bounds: WindowState): boolean {
|
||||
if (
|
||||
bounds.width === undefined ||
|
||||
bounds.height === undefined ||
|
||||
bounds.x === undefined ||
|
||||
bounds.y === undefined
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const displays = screen.getAllDisplays();
|
||||
return displays.some((display) => {
|
||||
const { x, y, width, height } = display.workArea;
|
||||
const horizontally =
|
||||
bounds.x < x + width && bounds.x + bounds.width > x;
|
||||
const vertically =
|
||||
bounds.y < y + height && bounds.y + bounds.height > y;
|
||||
return horizontally && vertically;
|
||||
});
|
||||
}
|
||||
|
||||
function loadWindowState(): WindowState {
|
||||
try {
|
||||
const file = getWindowStatePath();
|
||||
if (fs.existsSync(file)) {
|
||||
const parsed = JSON.parse(
|
||||
fs.readFileSync(file, "utf-8"),
|
||||
) as WindowState;
|
||||
if (
|
||||
parsed.width !== undefined &&
|
||||
parsed.height !== undefined &&
|
||||
parsed.width >= MIN_WINDOW_SIZE.width &&
|
||||
parsed.height >= MIN_WINDOW_SIZE.height &&
|
||||
isVisible(parsed)
|
||||
) {
|
||||
return parsed;
|
||||
}
|
||||
logger.warn({ parsed }, "已保存的窗口状态无效,使用默认尺寸");
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn({ err }, "读取窗口状态失败,使用默认尺寸");
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function saveWindowState(state: WindowState): void {
|
||||
try {
|
||||
fs.writeFileSync(getWindowStatePath(), JSON.stringify(state));
|
||||
logger.info({ state }, "已保存窗口状态");
|
||||
} catch (err) {
|
||||
logger.warn({ err }, "保存窗口状态失败");
|
||||
}
|
||||
}
|
||||
|
||||
let saveBoundsTimer: NodeJS.Timeout | null = null;
|
||||
function scheduleSaveBounds(winInstance: BrowserWindow): void {
|
||||
if (saveBoundsTimer) clearTimeout(saveBoundsTimer);
|
||||
saveBoundsTimer = setTimeout(() => {
|
||||
if (winInstance.isDestroyed()) return;
|
||||
const { width, height, x, y } = winInstance.getBounds();
|
||||
saveWindowState({
|
||||
width,
|
||||
height,
|
||||
x,
|
||||
y,
|
||||
maximized: winInstance.isMaximized(),
|
||||
});
|
||||
}, 400);
|
||||
}
|
||||
|
||||
async function createWindow() {
|
||||
const saved = loadWindowState();
|
||||
const mainWindow = new BrowserWindow({
|
||||
title: "星火应用商店",
|
||||
width: 1366,
|
||||
height: 768,
|
||||
width: saved.width ?? DEFAULT_WINDOW_SIZE.width,
|
||||
height: saved.height ?? DEFAULT_WINDOW_SIZE.height,
|
||||
x: saved.x,
|
||||
y: saved.y,
|
||||
minWidth: MIN_WINDOW_SIZE.width,
|
||||
minHeight: MIN_WINDOW_SIZE.height,
|
||||
frame: false,
|
||||
autoHideMenuBar: true,
|
||||
icon: path.join(process.env.VITE_PUBLIC, "favicon.ico"),
|
||||
@@ -290,6 +398,18 @@ async function createWindow() {
|
||||
});
|
||||
win = mainWindow;
|
||||
|
||||
// 恢复上一次的最大化状态
|
||||
if (saved.maximized) {
|
||||
mainWindow.maximize();
|
||||
}
|
||||
logger.info({ saved }, "已恢复窗口状态");
|
||||
|
||||
// 窗口大小/位置/最大化变化后防抖保存,下次启动时恢复
|
||||
// 位置/最大化变化由主进程事件保存;尺寸变化由渲染端 DOM resize 经 IPC 兜底保存
|
||||
mainWindow.on("moved", () => scheduleSaveBounds(mainWindow));
|
||||
mainWindow.on("maximize", () => scheduleSaveBounds(mainWindow));
|
||||
mainWindow.on("unmaximize", () => scheduleSaveBounds(mainWindow));
|
||||
|
||||
if (VITE_DEV_SERVER_URL) {
|
||||
// #298
|
||||
mainWindow.loadURL(VITE_DEV_SERVER_URL);
|
||||
@@ -323,6 +443,15 @@ async function createWindow() {
|
||||
|
||||
mainWindow.on("close", (event) => {
|
||||
if (allowAppExit) {
|
||||
// 真正退出前同步保存最终窗口尺寸(防抖可能尚未触发)
|
||||
const { width, height, x, y } = mainWindow.getBounds();
|
||||
saveWindowState({
|
||||
width,
|
||||
height,
|
||||
x,
|
||||
y,
|
||||
maximized: mainWindow.isMaximized(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { ipcRenderer, contextBridge, type IpcRendererEvent } from "electron";
|
||||
import {
|
||||
ipcRenderer,
|
||||
contextBridge,
|
||||
webUtils,
|
||||
type IpcRendererEvent,
|
||||
} from "electron";
|
||||
|
||||
type StoreFilter = "spark" | "apm" | "both";
|
||||
|
||||
@@ -97,6 +102,10 @@ contextBridge.exposeInMainWorld("apm_store", {
|
||||
})(),
|
||||
});
|
||||
|
||||
contextBridge.exposeInMainWorld("electronUtils", {
|
||||
getPathForFile: (file: File): string => webUtils.getPathForFile(file),
|
||||
});
|
||||
|
||||
contextBridge.exposeInMainWorld("windowControls", {
|
||||
minimize: () => ipcRenderer.send("window-control-minimize"),
|
||||
toggleMaximize: () => ipcRenderer.send("window-control-toggle-maximize"),
|
||||
|
||||
@@ -3,7 +3,7 @@ Description=Spark Store update notifier
|
||||
After=apt-daily.service network.target network-online.target systemd-networkd.service NetworkManager.service connman.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
Type=simple
|
||||
RemainAfterExit=yes
|
||||
ExecStart=/opt/durapps/spark-store/bin/update-upgrade/ss-update-notifier.sh
|
||||
Restart=on-failure
|
||||
|
||||
+209
-73
@@ -73,7 +73,9 @@
|
||||
:category-counts="categoryCounts"
|
||||
@select-category="selectSubCategory"
|
||||
/>
|
||||
<div class="flex min-h-0 flex-1 flex-col px-4 py-6 lg:px-10">
|
||||
<div
|
||||
class="flex min-h-0 flex-1 flex-col overflow-hidden px-4 py-6 lg:px-10"
|
||||
>
|
||||
<FavoriteFolderManager
|
||||
v-if="currentView === 'favorites'"
|
||||
:folders="favoriteFolders"
|
||||
@@ -172,10 +174,7 @@
|
||||
:apps="installedApps"
|
||||
:loading="installedLoading"
|
||||
:error="installedError"
|
||||
:active-origin="activeInstalledOrigin"
|
||||
:store-filter="storeFilter"
|
||||
:spark-available="sparkAvailable"
|
||||
:apm-available="apmAvailable"
|
||||
:warning="installedWarning"
|
||||
:logged-in="isLoggedIn"
|
||||
:syncing="syncLoading"
|
||||
:sync-message="syncStatusMessage"
|
||||
@@ -184,7 +183,6 @@
|
||||
@open-app="openDownloadedApp($event.pkgname, $event.origin)"
|
||||
@open-detail="openDetail"
|
||||
@uninstall="uninstallInstalledApp"
|
||||
@switch-origin="handleSwitchOrigin"
|
||||
@sync-to-account="syncInstalledAppsToAccount"
|
||||
@restore-from-account="openRestoreFromAccount"
|
||||
@request-login="requireLogin('云端同步需要登录星火账号。')"
|
||||
@@ -287,7 +285,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from "vue";
|
||||
import axios from "axios";
|
||||
import axios, { AxiosError } from "axios";
|
||||
import pino from "pino";
|
||||
import AppSidebar from "./components/AppSidebar.vue";
|
||||
import AppHeader from "./components/AppHeader.vue";
|
||||
@@ -362,10 +360,9 @@ import {
|
||||
setAuthSession,
|
||||
} from "./global/authState";
|
||||
import {
|
||||
getAllowedInstalledOrigin,
|
||||
getEffectiveStoreFilter,
|
||||
getDefaultInstalledOrigin,
|
||||
isOriginEnabled,
|
||||
isOriginUsable,
|
||||
} from "./modules/storeFilter";
|
||||
import { createUpdateCenterStore } from "./modules/updateCenter";
|
||||
import {
|
||||
@@ -424,7 +421,16 @@ const fetchWithRetry = async <T,>(
|
||||
const response = await axiosInstance.get<T>(url);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
if (retries > 0) {
|
||||
const axiosError = error as AxiosError;
|
||||
const status = axiosError.response?.status;
|
||||
// 仅对网络错误(无响应)、服务端 5xx 错误或超时进行重试;
|
||||
// 4xx(如 404/400)属于明确的客户端错误,直接抛出以避免无谓重试。
|
||||
const isNetworkError = status === undefined;
|
||||
const isServerError = typeof status === "number" && status >= 500;
|
||||
const isTimeout =
|
||||
axiosError.code === "ECONNABORTED" || axiosError.code === "ETIMEDOUT";
|
||||
|
||||
if (retries > 0 && (isNetworkError || isServerError || isTimeout)) {
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
return fetchWithRetry(url, retries - 1, delay * 2);
|
||||
}
|
||||
@@ -432,6 +438,44 @@ const fetchWithRetry = async <T,>(
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染进程从 IPC 拿到的 result.apps 实际类型为 any(ipcRenderer.invoke 返回 Promise<any>),
|
||||
// 直接断言成 InstalledAppInfo[] 会绕过运行时类型检查。后端字段缺失时会引发运行时错误。
|
||||
// 此守卫仅校验本项目实际使用的关键字段,后端字段缺失时跳过即可,避免整批失败。
|
||||
const isInstalledAppInfo = (value: unknown): value is InstalledAppInfo => {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
const v = value as Partial<InstalledAppInfo>;
|
||||
return (
|
||||
typeof v.pkgname === "string" &&
|
||||
typeof v.name === "string" &&
|
||||
typeof v.version === "string" &&
|
||||
typeof v.arch === "string" &&
|
||||
(v.origin === "spark" || v.origin === "apm") &&
|
||||
typeof v.flags === "string" &&
|
||||
typeof v.isDependency === "boolean" &&
|
||||
// icon 在类型中为可选字段(string | undefined),需显式校验其类型,
|
||||
// 避免非字符串值进入下游 app.icon || "" 触发隐式转换异常
|
||||
(typeof v.icon === "string" || v.icon === undefined)
|
||||
);
|
||||
};
|
||||
|
||||
// 单来源已安装查询超时时间:某个来源(如 APM)响应极慢或挂起时,
|
||||
// 不应阻塞其它来源整体返回,超时后该来源标记为失败并走 warning/error 流程。
|
||||
const LIST_INSTALLED_TIMEOUT_MS = 15000;
|
||||
|
||||
// 为 Promise 增加超时控制:超时即 reject,配合 Promise.allSettled 让单来源失败不影响其它来源。
|
||||
const withTimeout = <T,>(
|
||||
promise: Promise<T>,
|
||||
ms: number,
|
||||
label: string,
|
||||
): Promise<T> => {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise<T>((_, reject) =>
|
||||
setTimeout(() => reject(new Error(`${label} 超时(${ms}ms)`)), ms),
|
||||
),
|
||||
]);
|
||||
};
|
||||
|
||||
// 响应式状态
|
||||
const themeMode = ref<"light" | "dark" | "auto">("auto");
|
||||
const systemIsDark = ref(
|
||||
@@ -468,10 +512,13 @@ const loading = ref(true);
|
||||
const showDownloadDetailModal = ref(false);
|
||||
const currentDownload: Ref<DownloadItem | null> = ref(null);
|
||||
const showInstalledModal = ref(false);
|
||||
const activeInstalledOrigin = ref<"apm" | "spark">("apm");
|
||||
const installedApps = ref<App[]>([]);
|
||||
const installedLoading = ref(false);
|
||||
const installedError = ref("");
|
||||
// 部分来源失败(如 Spark/APM 之一不可用)时使用更轻量的 warning 提示,避免与已有列表同时呈现红色致命错误条造成 UX 混淆
|
||||
const installedWarning = ref("");
|
||||
// refreshInstalledApps 的代次计数器,用于异步竞态防护:新进入一次刷新自增,await 后若代次已变则放弃本轮写入
|
||||
const installedRefreshGeneration = ref(0);
|
||||
const updateCenterStore = createUpdateCenterStore();
|
||||
const showUninstallModal = ref(false);
|
||||
const uninstallTargetApp: Ref<App | null> = ref(null);
|
||||
@@ -613,7 +660,7 @@ const categoryCounts = computed(() => {
|
||||
return { ...tabCounts, all: allCounts.all };
|
||||
}
|
||||
|
||||
const counts: Record<string, number> = { all: apps.value.length };
|
||||
const counts: Record<string, number> = { all: baseApps.value.length };
|
||||
sourceApps.forEach((app) => {
|
||||
if (!counts[app.category]) counts[app.category] = 0;
|
||||
counts[app.category]++;
|
||||
@@ -1379,81 +1426,104 @@ const confirmMigrationStart = async () => {
|
||||
};
|
||||
|
||||
const openInstalledModal = () => {
|
||||
const defaultOrigin = getDefaultInstalledOrigin(
|
||||
storeFilter.value,
|
||||
availableSources.value,
|
||||
);
|
||||
if (!defaultOrigin) {
|
||||
if (
|
||||
getEffectiveStoreFilter(storeFilter.value, availableSources.value) === null
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
showInstalledModal.value = true;
|
||||
activeInstalledOrigin.value =
|
||||
getAllowedInstalledOrigin(
|
||||
storeFilter.value,
|
||||
activeInstalledOrigin.value,
|
||||
availableSources.value,
|
||||
) ?? defaultOrigin;
|
||||
refreshInstalledApps();
|
||||
};
|
||||
|
||||
const closeInstalledModal = () => {
|
||||
showInstalledModal.value = false;
|
||||
// 关闭模态框时同步清空错误 / 警告,避免下次打开时残留过期状态
|
||||
installedError.value = "";
|
||||
installedWarning.value = "";
|
||||
};
|
||||
|
||||
const handleSwitchOrigin = (origin: "apm" | "spark") => {
|
||||
activeInstalledOrigin.value =
|
||||
getAllowedInstalledOrigin(
|
||||
storeFilter.value,
|
||||
origin,
|
||||
availableSources.value,
|
||||
) ?? activeInstalledOrigin.value;
|
||||
refreshInstalledApps();
|
||||
// 根据当前启动模式和系统可用性,确定需要查询哪些 origin 的已安装应用
|
||||
const resolveInstalledOrigins = (): Array<"spark" | "apm"> => {
|
||||
const origins: Array<"spark" | "apm"> = [];
|
||||
if (isOriginUsable(storeFilter.value, "spark", availableSources.value)) {
|
||||
origins.push("spark");
|
||||
}
|
||||
if (isOriginUsable(storeFilter.value, "apm", availableSources.value)) {
|
||||
origins.push("apm");
|
||||
}
|
||||
return origins;
|
||||
};
|
||||
|
||||
const refreshInstalledApps = async () => {
|
||||
// 异步竞态防护:进入时若模态框已关闭(极小概率),直接放弃本轮请求
|
||||
if (!showInstalledModal.value) return;
|
||||
installedLoading.value = true;
|
||||
installedError.value = "";
|
||||
installedWarning.value = "";
|
||||
// 代次计数器:每次进入自增;await 之后若代次已变(新一轮刷新 / 关闭重开),丢弃本轮结果
|
||||
const generation = ++installedRefreshGeneration.value;
|
||||
try {
|
||||
const origin = getAllowedInstalledOrigin(
|
||||
storeFilter.value,
|
||||
activeInstalledOrigin.value,
|
||||
availableSources.value,
|
||||
);
|
||||
if (!origin) {
|
||||
installedApps.value = [];
|
||||
installedError.value = "当前系统不可用应用管理功能";
|
||||
return;
|
||||
}
|
||||
|
||||
activeInstalledOrigin.value = origin;
|
||||
|
||||
if (!isOriginEnabled(storeFilter.value, origin)) {
|
||||
installedApps.value = [];
|
||||
installedError.value = `当前启动模式已禁用 ${origin === "spark" ? "Spark" : "APM"} 软件管理`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Spark 优化:只检查远端商店目录中的应用,避免全量扫描
|
||||
let pkgnameList: string[] | undefined;
|
||||
if (origin === "spark") {
|
||||
pkgnameList = apps.value
|
||||
const origins = resolveInstalledOrigins();
|
||||
// Spark 已安装列表依赖商店目录(apps.value)来枚举包名。
|
||||
// 仅当目录中存在可枚举的 Spark 包时才查询 Spark,
|
||||
// 否则空目录会触发"全量扫描整个系统"的陷阱导致列表被全部跳过而误报为空。
|
||||
const sparkPkgnameList = apps.value
|
||||
.filter((a) => a.origin === "spark")
|
||||
.map((a) => a.pkgname);
|
||||
}
|
||||
const effectiveOrigins = origins.filter(
|
||||
(o) => o !== "spark" || sparkPkgnameList.length > 0,
|
||||
);
|
||||
|
||||
const result = await window.ipcRenderer.invoke("list-installed", {
|
||||
origin,
|
||||
pkgnameList,
|
||||
});
|
||||
if (!result?.success) {
|
||||
if (effectiveOrigins.length === 0) {
|
||||
installedApps.value = [];
|
||||
installedError.value = result?.message || "读取已安装应用失败";
|
||||
// 目录尚未加载完成(但来源可用)时给出过渡提示,待目录加载后会自动重查
|
||||
installedError.value =
|
||||
apps.value.length === 0
|
||||
? "正在加载应用目录,请稍候…"
|
||||
: "当前系统不可用应用管理功能";
|
||||
return;
|
||||
}
|
||||
|
||||
installedApps.value = [];
|
||||
for (const app of result.apps) {
|
||||
// 并行查询每个 origin 的已安装应用:
|
||||
// 用 allSettled + 每源超时,避免单一来源(如 APM)响应慢/挂起阻塞整体;
|
||||
// 超时或失败的来源在下方循环标记为 failedOrigin,不影响其它来源结果。
|
||||
const results = await Promise.allSettled(
|
||||
effectiveOrigins.map((origin) =>
|
||||
withTimeout(
|
||||
window.ipcRenderer.invoke("list-installed", {
|
||||
origin,
|
||||
pkgnameList: origin === "spark" ? sparkPkgnameList : undefined,
|
||||
}),
|
||||
LIST_INSTALLED_TIMEOUT_MS,
|
||||
`${origin} list-installed`,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// 异步结束后的回调阶段重新校验代次与模态可见性,避免陈旧竞态写入 ref
|
||||
if (generation !== installedRefreshGeneration.value) return;
|
||||
if (!showInstalledModal.value) return;
|
||||
|
||||
const combinedApps: App[] = [];
|
||||
const failedOrigins: string[] = [];
|
||||
|
||||
for (let i = 0; i < effectiveOrigins.length; i++) {
|
||||
const origin = effectiveOrigins[i];
|
||||
const settled = results[i];
|
||||
// allSettled: rejected(超时/异常)或 success=false 都视为该来源失败
|
||||
if (settled.status !== "fulfilled" || !settled.value?.success) {
|
||||
failedOrigins.push(origin);
|
||||
continue;
|
||||
}
|
||||
const result = settled.value;
|
||||
|
||||
const appList = Array.isArray(result?.apps) ? result.apps : [];
|
||||
for (const rawApp of appList) {
|
||||
// 运行时类型守卫,避免后端字段缺失造成下游访问 undefined 抛出
|
||||
if (!isInstalledAppInfo(rawApp)) continue;
|
||||
const app = rawApp;
|
||||
|
||||
// Find matching remote app to enrich data. We look exactly for that origin.
|
||||
let appInfo = apps.value.find(
|
||||
(a) => a.pkgname === app.pkgname && a.origin === origin,
|
||||
@@ -1494,16 +1564,65 @@ const refreshInstalledApps = async () => {
|
||||
isDependency: app.isDependency,
|
||||
};
|
||||
}
|
||||
installedApps.value.push(appInfo);
|
||||
combinedApps.push(appInfo);
|
||||
}
|
||||
}
|
||||
|
||||
installedApps.value = combinedApps;
|
||||
|
||||
// 部分来源失败使用轻量 warning(不与列表同时呈现红色致命错误条),致命/全失败仍用 error
|
||||
if (failedOrigins.length > 0) {
|
||||
const labels = failedOrigins
|
||||
.map((o) => (o === "spark" ? "Spark" : "APM"))
|
||||
.join("、");
|
||||
if (combinedApps.length > 0) {
|
||||
installedWarning.value = `部分来源加载失败(${labels}),已安装列表可能不完整`;
|
||||
} else {
|
||||
installedError.value = `读取${labels}已安装应用失败`;
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (generation !== installedRefreshGeneration.value) return;
|
||||
if (!showInstalledModal.value) return;
|
||||
installedApps.value = [];
|
||||
installedError.value = (error as Error)?.message || "读取已安装应用失败";
|
||||
} finally {
|
||||
// 仅最末一代次负责清理 loading,防止陈旧代次提前关闭 loading 影响后续刷新
|
||||
if (generation === installedRefreshGeneration.value) {
|
||||
installedLoading.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 应用目录(apps.value)可能在打开"已安装应用"模态框之后才加载完成。
|
||||
// 当目录长度变化、且模态框处于打开状态时,自动重查已安装应用,避免列表一直为空。
|
||||
// 注意:上一版仅在 prevLen===0→len>0 触发,会遗漏目录后续更新(例如分类切换触发目录重建)。
|
||||
// 现改为 len 任意 >0 的正向变化都允许触发;密集分批推送由下方 300ms 防抖合并最后一次写入。
|
||||
// 每次变化都先自增 installedRefreshGeneration:即使上一轮刷新仍在加载中,也会立即失效,
|
||||
// 避免用陈旧目录数据覆盖已安装列表(清理不单纯依赖定时器,代次校验兜底)。
|
||||
let refreshDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
watch(
|
||||
() => apps.value.length,
|
||||
(len) => {
|
||||
if (!showInstalledModal.value || len <= 0) {
|
||||
return;
|
||||
}
|
||||
installedRefreshGeneration.value++;
|
||||
if (refreshDebounceTimer !== null) {
|
||||
clearTimeout(refreshDebounceTimer);
|
||||
}
|
||||
refreshDebounceTimer = setTimeout(() => {
|
||||
refreshDebounceTimer = null;
|
||||
void refreshInstalledApps();
|
||||
}, 300);
|
||||
},
|
||||
);
|
||||
onUnmounted(() => {
|
||||
if (refreshDebounceTimer !== null) {
|
||||
clearTimeout(refreshDebounceTimer);
|
||||
}
|
||||
});
|
||||
|
||||
const mapInstalledAppToCatalogApp = (
|
||||
app: InstalledAppInfo,
|
||||
origin: "spark" | "apm",
|
||||
@@ -1573,8 +1692,11 @@ const refreshFavoriteInstalledApps = async (): Promise<void> => {
|
||||
});
|
||||
if (!result?.success) return;
|
||||
|
||||
for (const app of result.apps as InstalledAppInfo[]) {
|
||||
const appInfo = mapInstalledAppToCatalogApp(app, origin);
|
||||
const appList = Array.isArray(result?.apps) ? result.apps : [];
|
||||
for (const rawApp of appList) {
|
||||
// 运行时类型守卫:避免后端字段缺失时下游访问 undefined
|
||||
if (!isInstalledAppInfo(rawApp)) continue;
|
||||
const appInfo = mapInstalledAppToCatalogApp(rawApp, origin);
|
||||
if (appInfo) refreshedApps.push(appInfo);
|
||||
}
|
||||
}),
|
||||
@@ -1916,8 +2038,11 @@ const refreshInstalledSyncCandidates = async (
|
||||
});
|
||||
if (!result?.success) return;
|
||||
|
||||
for (const app of result.apps as InstalledAppInfo[]) {
|
||||
const appInfo = mapInstalledAppToCatalogApp(app, origin);
|
||||
const appList = Array.isArray(result?.apps) ? result.apps : [];
|
||||
for (const rawApp of appList) {
|
||||
// 运行时类型守卫:避免后端字段缺失时下游访问 undefined
|
||||
if (!isInstalledAppInfo(rawApp)) continue;
|
||||
const appInfo = mapInstalledAppToCatalogApp(rawApp, origin);
|
||||
if (appInfo) refreshedApps.push(appInfo);
|
||||
}
|
||||
}),
|
||||
@@ -2450,7 +2575,9 @@ const openDownloadedApp = (pkgname: string, origin?: "spark" | "apm") => {
|
||||
// openApmStoreUrl(`apmstore://launch?pkg=${encodedPkg}`, {
|
||||
// fallbackText: `打开应用: ${download.pkgname}`
|
||||
// });
|
||||
window.ipcRenderer.invoke("launch-app", { pkgname, origin });
|
||||
window.ipcRenderer
|
||||
.invoke("launch-app", { pkgname, origin })
|
||||
.catch((err) => logger.error("启动应用失败 (launch-app):", err));
|
||||
};
|
||||
|
||||
const loadCategories = async () => {
|
||||
@@ -2794,6 +2921,15 @@ const handleSearchFocus = () => {
|
||||
if (activeTab.value === "home") activeTab.value = "all";
|
||||
};
|
||||
|
||||
// 窗口尺寸变化(含无边框窗口鼠标拉边角)时,防抖通知主进程保存当前尺寸
|
||||
let saveBoundsTimer: number | undefined;
|
||||
const handleWindowResize = () => {
|
||||
if (saveBoundsTimer) clearTimeout(saveBoundsTimer);
|
||||
saveBoundsTimer = window.setTimeout(() => {
|
||||
void window.ipcRenderer.invoke("save-window-bounds");
|
||||
}, 400);
|
||||
};
|
||||
|
||||
// 生命周期钩子
|
||||
onMounted(async () => {
|
||||
initTheme();
|
||||
@@ -2806,6 +2942,9 @@ onMounted(async () => {
|
||||
handleHashChange();
|
||||
window.addEventListener("hashchange", handleHashChange);
|
||||
|
||||
// 窗口尺寸变化(含无边框窗口鼠标拉边角)时,防抖通知主进程保存当前尺寸
|
||||
window.addEventListener("resize", handleWindowResize);
|
||||
|
||||
try {
|
||||
systemInfo.value = await window.ipcRenderer.invoke("get-system-info");
|
||||
} catch (error: unknown) {
|
||||
@@ -2827,10 +2966,6 @@ onMounted(async () => {
|
||||
apmAvailable.value = await window.ipcRenderer.invoke("check-apm-available");
|
||||
}
|
||||
|
||||
activeInstalledOrigin.value =
|
||||
getDefaultInstalledOrigin(storeFilter.value, availableSources.value) ??
|
||||
"spark";
|
||||
|
||||
await loadCategories();
|
||||
|
||||
await loadSidebarConfig();
|
||||
@@ -2988,6 +3123,7 @@ onUnmounted(() => {
|
||||
"install-complete",
|
||||
handleInstallCompleteForDownloadRecord,
|
||||
);
|
||||
window.removeEventListener("resize", handleWindowResize);
|
||||
});
|
||||
|
||||
// 观察器
|
||||
|
||||
@@ -26,21 +26,20 @@ const createApp = (overrides: Partial<App> = {}): App => ({
|
||||
});
|
||||
|
||||
describe("InstalledAppsModal", () => {
|
||||
it("keeps scroll chaining inside the modal list", () => {
|
||||
const { container } = render(InstalledAppsModal, {
|
||||
props: {
|
||||
const baseProps = {
|
||||
show: true,
|
||||
apps: [],
|
||||
apps: [] as App[],
|
||||
loading: false,
|
||||
error: "",
|
||||
activeOrigin: "spark",
|
||||
storeFilter: "both",
|
||||
sparkAvailable: true,
|
||||
apmAvailable: true,
|
||||
warning: "",
|
||||
loggedIn: false,
|
||||
syncing: false,
|
||||
syncMessage: "",
|
||||
},
|
||||
};
|
||||
|
||||
it("keeps scroll chaining inside the modal list", () => {
|
||||
const { container } = render(InstalledAppsModal, {
|
||||
props: baseProps,
|
||||
});
|
||||
|
||||
expect(screen.getByText("已安装应用")).toBeTruthy();
|
||||
@@ -52,17 +51,8 @@ describe("InstalledAppsModal", () => {
|
||||
it("renders open and detail actions for a store-backed installed app", () => {
|
||||
render(InstalledAppsModal, {
|
||||
props: {
|
||||
show: true,
|
||||
...baseProps,
|
||||
apps: [createApp()],
|
||||
loading: false,
|
||||
error: "",
|
||||
activeOrigin: "spark",
|
||||
storeFilter: "both",
|
||||
sparkAvailable: true,
|
||||
apmAvailable: true,
|
||||
loggedIn: false,
|
||||
syncing: false,
|
||||
syncMessage: "",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -70,20 +60,42 @@ describe("InstalledAppsModal", () => {
|
||||
expect(screen.getByRole("button", { name: "查看详情" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the spark origin tag for spark apps", () => {
|
||||
render(InstalledAppsModal, {
|
||||
props: {
|
||||
...baseProps,
|
||||
apps: [createApp({ origin: "spark", name: "Spark Notes" })],
|
||||
},
|
||||
});
|
||||
|
||||
// 精确匹配应用行内的来源标签(页头统计区也有 "Spark" 文案,getAllByText 过于宽泛)
|
||||
expect(screen.getByTestId("origin-tag-spark")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the APM origin tag for APM apps", () => {
|
||||
render(InstalledAppsModal, {
|
||||
props: {
|
||||
...baseProps,
|
||||
apps: [
|
||||
createApp({
|
||||
origin: "apm",
|
||||
name: "APM Container",
|
||||
pkgname: "amber-pm-container",
|
||||
version: "1.0.0",
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// 精确匹配应用行内的来源标签(页头统计区也有 "APM" 文案,getAllByText 过于宽泛)
|
||||
expect(screen.getByTestId("origin-tag-apm")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("emits open-app when clicking 打开", async () => {
|
||||
const rendered = render(InstalledAppsModal, {
|
||||
props: {
|
||||
show: true,
|
||||
...baseProps,
|
||||
apps: [createApp()],
|
||||
loading: false,
|
||||
error: "",
|
||||
activeOrigin: "spark",
|
||||
storeFilter: "both",
|
||||
sparkAvailable: true,
|
||||
apmAvailable: true,
|
||||
loggedIn: false,
|
||||
syncing: false,
|
||||
syncMessage: "",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -98,17 +110,8 @@ describe("InstalledAppsModal", () => {
|
||||
it("emits open-detail when clicking 查看详情", async () => {
|
||||
const rendered = render(InstalledAppsModal, {
|
||||
props: {
|
||||
show: true,
|
||||
...baseProps,
|
||||
apps: [createApp()],
|
||||
loading: false,
|
||||
error: "",
|
||||
activeOrigin: "spark",
|
||||
storeFilter: "both",
|
||||
sparkAvailable: true,
|
||||
apmAvailable: true,
|
||||
loggedIn: false,
|
||||
syncing: false,
|
||||
syncMessage: "",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -123,17 +126,8 @@ describe("InstalledAppsModal", () => {
|
||||
it("shows 查看详情 for metadata-rich unknown-category apps", () => {
|
||||
render(InstalledAppsModal, {
|
||||
props: {
|
||||
show: true,
|
||||
...baseProps,
|
||||
apps: [createApp({ category: "unknown", more: "Has store metadata" })],
|
||||
loading: false,
|
||||
error: "",
|
||||
activeOrigin: "spark",
|
||||
storeFilter: "both",
|
||||
sparkAvailable: true,
|
||||
apmAvailable: true,
|
||||
loggedIn: false,
|
||||
syncing: false,
|
||||
syncMessage: "",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -143,17 +137,8 @@ describe("InstalledAppsModal", () => {
|
||||
it("hides 查看详情 for unknown-category apps", () => {
|
||||
render(InstalledAppsModal, {
|
||||
props: {
|
||||
show: true,
|
||||
...baseProps,
|
||||
apps: [createApp({ category: "unknown" })],
|
||||
loading: false,
|
||||
error: "",
|
||||
activeOrigin: "spark",
|
||||
storeFilter: "both",
|
||||
sparkAvailable: true,
|
||||
apmAvailable: true,
|
||||
loggedIn: false,
|
||||
syncing: false,
|
||||
syncMessage: "",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -162,19 +147,7 @@ describe("InstalledAppsModal", () => {
|
||||
|
||||
it("requests login for cloud actions when logged out", async () => {
|
||||
const rendered = render(InstalledAppsModal, {
|
||||
props: {
|
||||
show: true,
|
||||
apps: [],
|
||||
loading: false,
|
||||
error: "",
|
||||
activeOrigin: "spark",
|
||||
storeFilter: "both",
|
||||
sparkAvailable: true,
|
||||
apmAvailable: true,
|
||||
loggedIn: false,
|
||||
syncing: false,
|
||||
syncMessage: "",
|
||||
},
|
||||
props: baseProps,
|
||||
});
|
||||
|
||||
await fireEvent.click(screen.getByRole("button", { name: "同步到账号" }));
|
||||
@@ -186,17 +159,8 @@ describe("InstalledAppsModal", () => {
|
||||
it("emits cloud sync and restore events when logged in", async () => {
|
||||
const rendered = render(InstalledAppsModal, {
|
||||
props: {
|
||||
show: true,
|
||||
apps: [],
|
||||
loading: false,
|
||||
error: "",
|
||||
activeOrigin: "spark",
|
||||
storeFilter: "both",
|
||||
sparkAvailable: true,
|
||||
apmAvailable: true,
|
||||
...baseProps,
|
||||
loggedIn: true,
|
||||
syncing: false,
|
||||
syncMessage: "",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -210,17 +174,9 @@ describe("InstalledAppsModal", () => {
|
||||
it("disables sync button while syncing", () => {
|
||||
render(InstalledAppsModal, {
|
||||
props: {
|
||||
show: true,
|
||||
apps: [],
|
||||
loading: false,
|
||||
error: "",
|
||||
activeOrigin: "spark",
|
||||
storeFilter: "both",
|
||||
sparkAvailable: true,
|
||||
apmAvailable: true,
|
||||
...baseProps,
|
||||
loggedIn: true,
|
||||
syncing: true,
|
||||
syncMessage: "",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -230,20 +186,94 @@ describe("InstalledAppsModal", () => {
|
||||
it("shows account sync feedback in the installed apps modal", () => {
|
||||
render(InstalledAppsModal, {
|
||||
props: {
|
||||
show: true,
|
||||
apps: [],
|
||||
loading: false,
|
||||
error: "",
|
||||
activeOrigin: "spark",
|
||||
storeFilter: "both",
|
||||
sparkAvailable: true,
|
||||
apmAvailable: true,
|
||||
...baseProps,
|
||||
loggedIn: true,
|
||||
syncing: false,
|
||||
syncMessage: "同步完成",
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByText("同步完成")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("filters installed apps by search query (name match)", async () => {
|
||||
render(InstalledAppsModal, {
|
||||
props: {
|
||||
...baseProps,
|
||||
apps: [
|
||||
createApp({ name: "Spark Notes", pkgname: "spark-notes" }),
|
||||
createApp({
|
||||
name: "Visual Studio Code",
|
||||
pkgname: "code",
|
||||
category: "dev",
|
||||
more: "https://code.visualstudio.com/",
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// 初始两条都在
|
||||
expect(screen.getByText("Spark Notes")).toBeTruthy();
|
||||
expect(screen.getByText("Visual Studio Code")).toBeTruthy();
|
||||
|
||||
const input = screen.getByPlaceholderText("搜索已安装应用…");
|
||||
await fireEvent.update(input, "code");
|
||||
|
||||
// "code" 只匹配到 pkgname 为 "code" 的项(名称不区分大小写)
|
||||
expect(screen.queryByText("Spark Notes")).toBeNull();
|
||||
expect(screen.getByText("Visual Studio Code")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("filters installed apps by search query (case-insensitive)", async () => {
|
||||
render(InstalledAppsModal, {
|
||||
props: {
|
||||
...baseProps,
|
||||
apps: [
|
||||
createApp({ name: "钉钉", pkgname: "com.alibaba.dingtalk" }),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const input = screen.getByPlaceholderText("搜索已安装应用…");
|
||||
await fireEvent.update(input, "DINGTALK");
|
||||
|
||||
// 包名大写不区分大小写匹配
|
||||
expect(screen.getByText("钉钉")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows a no-match hint when search query has no results", async () => {
|
||||
render(InstalledAppsModal, {
|
||||
props: {
|
||||
...baseProps,
|
||||
apps: [createApp({ name: "Spark Notes" })],
|
||||
},
|
||||
});
|
||||
|
||||
const input = screen.getByPlaceholderText("搜索已安装应用…");
|
||||
await fireEvent.update(input, "不存在的关键字xyz");
|
||||
|
||||
expect(screen.queryByText("Spark Notes")).toBeNull();
|
||||
expect(screen.getByText(/未找到匹配/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("clears the search when clicking the clear button", async () => {
|
||||
render(InstalledAppsModal, {
|
||||
props: {
|
||||
...baseProps,
|
||||
apps: [createApp()],
|
||||
},
|
||||
});
|
||||
|
||||
const input = screen.getByPlaceholderText(
|
||||
"搜索已安装应用…",
|
||||
) as HTMLInputElement;
|
||||
await fireEvent.update(input, "code");
|
||||
expect(input.value).toBe("code");
|
||||
|
||||
// 清除按钮存在(仅在有内容时显示)
|
||||
const clearBtn = screen.getByRole("button", { name: "清除搜索" });
|
||||
await fireEvent.click(clearBtn);
|
||||
|
||||
expect(input.value).toBe("");
|
||||
expect(screen.getByText("Spark Notes")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
--color-card-light: #ffffff;
|
||||
--color-card-dark: #151c2c;
|
||||
|
||||
--color-scrollbar-thumb-light: #94a3b8;
|
||||
--color-scrollbar-thumb-dark: #15203b;
|
||||
|
||||
--shadow-glass: 0 10px 30px rgba(15,23,42,0.08);
|
||||
--shadow-glassDark: 0 20px 45px rgba(0,0,0,0.45);
|
||||
|
||||
@@ -22,6 +25,14 @@
|
||||
@variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
background-color: var(--color-surface-light);
|
||||
@@ -38,12 +49,50 @@
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* 全局滚动条基础几何:置于 base,使 utilities 中的 .scrollbar-muted/.scrollbar-nowidth 可覆盖 */
|
||||
*::-webkit-scrollbar {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* 亮色滚动条:与暗色保持相同几何(透明边距),确保亮/暗滚动块大小一致 */
|
||||
html ::-webkit-scrollbar-thumb {
|
||||
background-color: var(--color-scrollbar-thumb-light);
|
||||
border-radius: 9999px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
html ::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* 暗色滚动条:挂在 html.dark 上(root),比 surface 略亮以保留可辨识反差 (#15203b),
|
||||
注意:必须用 html.dark 而非 .dark *,因为经典滚动条只作用于滚动容器自身,
|
||||
.dark * 在亮色模式不命中会导致回退到原生宽度,亮/暗宽度不一致。 */
|
||||
html.dark ::-webkit-scrollbar-thumb {
|
||||
background-color: var(--color-scrollbar-thumb-dark);
|
||||
border-radius: 9999px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
html.dark ::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* 弱化滚动条 */
|
||||
.scrollbar-muted {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #94a3b8 transparent;
|
||||
scrollbar-color: var(--color-scrollbar-thumb-light) transparent;
|
||||
}
|
||||
|
||||
.scrollbar-muted::-webkit-scrollbar {
|
||||
@@ -55,10 +104,19 @@
|
||||
}
|
||||
|
||||
.scrollbar-muted::-webkit-scrollbar-thumb {
|
||||
background-color: #94a3b8;
|
||||
background-color: var(--color-scrollbar-thumb-light);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
html.dark .scrollbar-muted {
|
||||
scrollbar-color: var(--color-surface-dark) transparent;
|
||||
}
|
||||
|
||||
html.dark .scrollbar-muted::-webkit-scrollbar-thumb {
|
||||
background-color: var(--color-surface-dark);
|
||||
}
|
||||
|
||||
/* 完全隐藏滚动条 */
|
||||
.scrollbar-nowidth {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
@@ -97,6 +97,7 @@
|
||||
<button
|
||||
v-if="app.sparkApp"
|
||||
type="button"
|
||||
title="原生安装,高手自定义"
|
||||
class="px-3 py-1 text-xs font-medium uppercase tracking-wider transition-colors"
|
||||
:class="
|
||||
viewingOrigin === 'spark'
|
||||
@@ -110,6 +111,7 @@
|
||||
<button
|
||||
v-if="app.apmApp"
|
||||
type="button"
|
||||
title="兼容安装,省心不折腾"
|
||||
class="px-3 py-1 text-xs font-medium uppercase tracking-wider transition-colors"
|
||||
:class="
|
||||
viewingOrigin === 'apm'
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
<button
|
||||
v-if="app.sparkApp"
|
||||
type="button"
|
||||
title="原生安装,高手自定义"
|
||||
class="px-3 py-1 text-xs font-medium uppercase tracking-wider transition-colors"
|
||||
:class="
|
||||
viewingOrigin === 'spark'
|
||||
@@ -64,6 +65,7 @@
|
||||
<button
|
||||
v-if="app.apmApp"
|
||||
type="button"
|
||||
title="兼容安装,省心不折腾"
|
||||
class="px-3 py-1 text-xs font-medium uppercase tracking-wider transition-colors"
|
||||
:class="
|
||||
viewingOrigin === 'apm'
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
<div v-else-if="!loading && apps.length <= 50" class="non-virtual-scroller">
|
||||
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||
<AppCard
|
||||
v-for="(app, index) in apps"
|
||||
:key="index"
|
||||
v-for="app in apps"
|
||||
:key="app.pkgname"
|
||||
:app="app"
|
||||
:show-origin="effectiveShowOrigin"
|
||||
@open-detail="$emit('open-detail', app)"
|
||||
@@ -182,12 +182,17 @@ const gridRows = computed(() => {
|
||||
.non-virtual-scroller {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
margin: -24px -16px; /* 抵消父容器的 px-4 py-6 */
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.scroller {
|
||||
margin: -24px -40px; /* 抵消父容器的 lg:px-10 */
|
||||
}
|
||||
|
||||
.non-virtual-scroller {
|
||||
margin: -24px -40px; /* 抵消父容器的 lg:px-10 */
|
||||
}
|
||||
}
|
||||
|
||||
.grid-row {
|
||||
@@ -205,7 +210,6 @@ const gridRows = computed(() => {
|
||||
flex: 1;
|
||||
box-sizing: border-box;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
:deep(.vue-recycle-scroller__item-view) {
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
ref="queueRef"
|
||||
class="fixed inset-x-4 bottom-4 z-40 rounded-3xl border border-slate-200/70 bg-white shadow-2xl transition-all duration-200 dark:border-slate-800/70 dark:bg-slate-900 sm:left-auto sm:right-6 sm:w-96"
|
||||
:class="
|
||||
isHidden
|
||||
? 'pointer-events-none translate-y-[calc(100%+1rem)] opacity-0'
|
||||
: 'translate-y-0 opacity-100'
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between px-5 py-4"
|
||||
@@ -136,7 +130,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from "vue";
|
||||
import { computed, ref } from "vue";
|
||||
import type { DownloadItem } from "../global/typedefinition";
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -153,30 +147,6 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
const isExpanded = ref(false);
|
||||
const isHidden = ref(false);
|
||||
const queueRef = ref<HTMLElement | null>(null);
|
||||
|
||||
const onWheel = (event: WheelEvent) => {
|
||||
if (queueRef.value?.contains(event.target as Node) || event.deltaY === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.deltaY > 0) {
|
||||
isExpanded.value = false;
|
||||
isHidden.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
isHidden.value = false;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener("wheel", onWheel, { passive: true, capture: true });
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener("wheel", onWheel, { capture: true });
|
||||
});
|
||||
|
||||
const completedDownloads = computed(() => {
|
||||
return props.downloads.filter((d) => d.status === "completed").length;
|
||||
|
||||
@@ -14,71 +14,142 @@
|
||||
@wheel="onOverlayWheel"
|
||||
>
|
||||
<div
|
||||
class="flex w-full max-w-4xl max-h-[85vh] flex-col rounded-3xl border border-white/10 bg-white/95 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
|
||||
class="flex w-full max-w-4xl max-h-[85vh] flex-col overflow-hidden rounded-3xl border border-white/10 bg-white/95 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
|
||||
>
|
||||
<div
|
||||
class="flex items-start justify-between border-b border-slate-200/70 p-6 dark:border-slate-800/70"
|
||||
>
|
||||
<div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-2xl font-semibold text-slate-900 dark:text-white">
|
||||
已安装应用
|
||||
</p>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400">
|
||||
管理本机安装的应用程序
|
||||
</p>
|
||||
<div
|
||||
v-if="!loading && !error"
|
||||
class="mt-2 flex flex-wrap items-center gap-3"
|
||||
>
|
||||
<div
|
||||
class="inline-flex flex-wrap items-stretch overflow-hidden rounded-2xl border border-slate-200/70 bg-slate-50/60 text-sm shadow-sm dark:border-slate-700/60 dark:bg-slate-800/40"
|
||||
>
|
||||
<!-- APM -->
|
||||
<div
|
||||
class="group flex cursor-pointer items-center gap-2.5 px-3.5 py-2 transition hover:bg-amber-50/80 dark:hover:bg-amber-500/10"
|
||||
:class="
|
||||
filterOrigin === 'apm'
|
||||
? 'bg-amber-100 ring-2 ring-amber-400/50 dark:bg-amber-500/20'
|
||||
: ''
|
||||
"
|
||||
title="仅显示 APM 应用"
|
||||
role="button"
|
||||
@click="filterOrigin = 'apm'"
|
||||
>
|
||||
<span
|
||||
class="flex h-7 w-7 items-center justify-center rounded-lg bg-gradient-to-br from-amber-400 to-orange-500 text-white shadow-sm shadow-amber-500/30"
|
||||
>
|
||||
<i class="fas fa-box-open text-[12px]"></i>
|
||||
</span>
|
||||
<div class="flex flex-col leading-tight">
|
||||
<span
|
||||
class="text-[15px] font-bold tabular-nums text-amber-700 dark:text-amber-300"
|
||||
>{{ apmCount }}</span
|
||||
>
|
||||
<span
|
||||
class="text-[10px] font-medium uppercase tracking-wider text-amber-600/80 dark:text-amber-400/80"
|
||||
>APM</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 分隔线 -->
|
||||
<span
|
||||
class="self-stretch w-px bg-slate-200/80 dark:bg-slate-700/80"
|
||||
></span>
|
||||
<!-- Spark -->
|
||||
<div
|
||||
class="group flex cursor-pointer items-center gap-2.5 px-3.5 py-2 transition hover:bg-sky-50/80 dark:hover:bg-sky-500/10"
|
||||
:class="
|
||||
filterOrigin === 'spark'
|
||||
? 'bg-sky-100 ring-2 ring-sky-400/50 dark:bg-sky-500/20'
|
||||
: ''
|
||||
"
|
||||
title="仅显示 Spark 应用"
|
||||
role="button"
|
||||
@click="filterOrigin = 'spark'"
|
||||
>
|
||||
<span
|
||||
class="flex h-7 w-7 items-center justify-center rounded-lg bg-gradient-to-br from-sky-400 to-blue-500 text-white shadow-sm shadow-sky-500/30"
|
||||
>
|
||||
<i class="fas fa-bolt text-[12px]"></i>
|
||||
</span>
|
||||
<div class="flex flex-col leading-tight">
|
||||
<span
|
||||
class="text-[15px] font-bold tabular-nums text-sky-700 dark:text-sky-300"
|
||||
>{{ sparkCount }}</span
|
||||
>
|
||||
<span
|
||||
class="text-[10px] font-medium uppercase tracking-wider text-sky-600/80 dark:text-sky-400/80"
|
||||
>Spark</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 分隔线 -->
|
||||
<span
|
||||
class="self-stretch w-px bg-slate-200/80 dark:bg-slate-700/80"
|
||||
></span>
|
||||
<!-- 总数 -->
|
||||
<div
|
||||
class="flex cursor-pointer items-center gap-2.5 px-3.5 py-2"
|
||||
:class="
|
||||
filterOrigin === 'all'
|
||||
? 'bg-slate-100 ring-2 ring-slate-400/40 dark:bg-slate-700/40'
|
||||
: ''
|
||||
"
|
||||
title="显示全部已安装应用"
|
||||
role="button"
|
||||
@click="filterOrigin = 'all'"
|
||||
>
|
||||
<span
|
||||
class="flex h-7 w-7 items-center justify-center rounded-lg bg-gradient-to-br from-slate-500 to-slate-700 text-white shadow-sm dark:from-slate-400 dark:to-slate-600"
|
||||
>
|
||||
<i class="fas fa-cubes text-[12px]"></i>
|
||||
</span>
|
||||
<div class="flex flex-col leading-tight">
|
||||
<span
|
||||
class="text-[15px] font-bold tabular-nums text-slate-900 dark:text-white"
|
||||
>{{ totalCount }}</span
|
||||
>
|
||||
<span
|
||||
class="text-[10px] font-medium uppercase tracking-wider text-slate-500 dark:text-slate-400"
|
||||
>总数</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 搜索框 -->
|
||||
<div class="relative flex-1 min-w-[200px]">
|
||||
<i
|
||||
class="fas fa-search pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-slate-400"
|
||||
></i>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="搜索已安装应用…"
|
||||
class="w-full rounded-2xl border border-slate-200/70 bg-slate-50/60 py-2 pl-9 pr-9 text-sm text-slate-700 placeholder-slate-400 transition focus:border-brand/60 focus:bg-white focus:outline-none focus:ring-2 focus:ring-brand/20 dark:border-slate-700 dark:bg-slate-800/40 dark:text-slate-200 dark:placeholder-slate-500 dark:focus:bg-slate-800"
|
||||
/>
|
||||
<button
|
||||
v-if="searchQuery"
|
||||
type="button"
|
||||
class="absolute right-2 top-1/2 inline-flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-full text-slate-400 transition hover:bg-slate-200/60 hover:text-slate-700 dark:hover:bg-slate-700 dark:hover:text-white"
|
||||
aria-label="清除搜索"
|
||||
@click="searchQuery = ''"
|
||||
>
|
||||
<i class="fas fa-xmark text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- 云端同步功能暂时关闭
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 rounded-2xl border border-brand/30 px-4 py-2 text-sm font-semibold text-brand transition hover:bg-brand/10 disabled:opacity-40"
|
||||
:disabled="syncing"
|
||||
@click="handleSyncClick"
|
||||
>
|
||||
<i class="fas fa-cloud-arrow-up"></i>
|
||||
{{ syncing ? "同步中" : "同步到账号" }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 rounded-2xl border border-slate-200/70 px-4 py-2 text-sm font-semibold text-slate-600 transition hover:bg-slate-50 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
|
||||
@click="handleRestoreClick"
|
||||
>
|
||||
<i class="fas fa-cloud-arrow-down"></i>
|
||||
从账号恢复
|
||||
</button>
|
||||
-->
|
||||
<div
|
||||
v-if="showOriginSwitcher"
|
||||
class="flex items-center rounded-2xl border border-slate-200/70 p-1 dark:border-slate-800/70"
|
||||
>
|
||||
<button
|
||||
v-if="apmEnabled"
|
||||
type="button"
|
||||
class="rounded-xl px-4 py-1.5 text-sm font-semibold transition"
|
||||
:class="
|
||||
activeOrigin === 'apm'
|
||||
? 'bg-brand/10 text-brand dark:bg-brand/15'
|
||||
: 'text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200'
|
||||
"
|
||||
:disabled="!apmAvailable"
|
||||
@click="$emit('switch-origin', 'apm')"
|
||||
>
|
||||
APM 软件
|
||||
</button>
|
||||
<button
|
||||
v-if="sparkEnabled"
|
||||
type="button"
|
||||
class="rounded-xl px-4 py-1.5 text-sm font-semibold transition"
|
||||
:class="
|
||||
activeOrigin === 'spark'
|
||||
? 'bg-brand/10 text-brand dark:bg-brand/15'
|
||||
: 'text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200'
|
||||
"
|
||||
@click="$emit('switch-origin', 'spark')"
|
||||
>
|
||||
Spark 软件
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 rounded-2xl border border-slate-200/70 px-4 py-2 text-sm font-semibold text-slate-600 transition hover:bg-slate-50 disabled:opacity-40 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
|
||||
@@ -100,7 +171,7 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex-1 overflow-y-auto overscroll-contain scrollbar-nowidth scrollbar-thumb-slate-200 dark:scrollbar-thumb-slate-700 p-6 space-y-4"
|
||||
class="flex-1 overflow-y-auto overscroll-contain p-6 space-y-4 mb-6"
|
||||
>
|
||||
<div
|
||||
v-if="syncMessage"
|
||||
@@ -120,15 +191,37 @@
|
||||
>
|
||||
{{ error }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="warning"
|
||||
class="rounded-2xl border border-amber-200/70 bg-amber-50/60 px-4 py-3 text-sm text-amber-700 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-300"
|
||||
>
|
||||
<i class="fas fa-triangle-exclamation mr-1.5"></i>
|
||||
{{ warning }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="apps.length === 0"
|
||||
class="rounded-2xl border border-slate-200/70 px-4 py-10 text-center text-slate-500 dark:border-slate-800/70 dark:text-slate-400"
|
||||
>
|
||||
暂无已安装应用
|
||||
</div>
|
||||
<div
|
||||
v-else-if="filteredApps.length === 0"
|
||||
class="rounded-2xl border border-slate-200/70 px-4 py-10 text-center text-slate-500 dark:border-slate-800/70 dark:text-slate-400"
|
||||
>
|
||||
<i class="fas fa-search mr-1.5 text-slate-400"></i>
|
||||
<template v-if="searchQuery">
|
||||
未找到匹配“<span class="font-semibold text-slate-700 dark:text-slate-300">{{ searchQuery }}</span>”的已安装应用
|
||||
</template>
|
||||
<template v-else-if="filterOrigin === 'apm'">
|
||||
暂无已安装的 APM 应用
|
||||
</template>
|
||||
<template v-else-if="filterOrigin === 'spark'">
|
||||
暂无已安装的 Spark 应用
|
||||
</template>
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="app in apps"
|
||||
v-for="app in filteredApps"
|
||||
:key="app.pkgname"
|
||||
class="flex flex-col gap-3 rounded-2xl border border-slate-200/70 bg-white/90 p-4 shadow-sm dark:border-slate-800/70 dark:bg-slate-900/70 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
@@ -155,6 +248,22 @@
|
||||
>
|
||||
{{ app.name }}
|
||||
</p>
|
||||
<span
|
||||
v-if="app.origin === 'apm'"
|
||||
data-testid="origin-tag-apm"
|
||||
class="rounded-md bg-amber-100 px-2 py-0.5 text-[11px] font-semibold text-amber-700 dark:bg-amber-500/20 dark:text-amber-400"
|
||||
title="APM 软件"
|
||||
>
|
||||
APM
|
||||
</span>
|
||||
<span
|
||||
v-else-if="app.origin === 'spark'"
|
||||
data-testid="origin-tag-spark"
|
||||
class="rounded-md bg-sky-100 px-2 py-0.5 text-[11px] font-semibold text-sky-700 dark:bg-sky-500/20 dark:text-sky-400"
|
||||
title="Spark 软件"
|
||||
>
|
||||
Spark
|
||||
</span>
|
||||
<span
|
||||
v-if="app.isDependency"
|
||||
class="rounded-md bg-rose-100 px-2 py-0.5 text-[11px] font-semibold text-rose-600 dark:bg-rose-500/20 dark:text-rose-400"
|
||||
@@ -212,14 +321,32 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive } from "vue";
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { App } from "../global/typedefinition";
|
||||
import { APM_STORE_BASE_URL } from "../global/storeConfig";
|
||||
|
||||
const iconErrors = reactive<Record<string, boolean>>({});
|
||||
|
||||
// 仅允许从这些常见图标目录读取本地图标,避免通过 app.icons 读取任意本地文件
|
||||
const ALLOWED_LOCAL_ICON_PREFIXES = [
|
||||
"/usr/share/",
|
||||
"/usr/lib/",
|
||||
"/usr/local/share/",
|
||||
"/opt/",
|
||||
"/var/lib/apm/",
|
||||
"/var/lib/",
|
||||
];
|
||||
|
||||
const getIconUrl = (app: App) => {
|
||||
if (app.icons && app.icons.startsWith("/")) return `file://${app.icons}`;
|
||||
// 本地图标:仅允许以白名单目录开头、且不含路径遍历("..")的绝对路径
|
||||
if (
|
||||
app.icons &&
|
||||
app.icons.startsWith("/") &&
|
||||
!app.icons.includes("..") &&
|
||||
ALLOWED_LOCAL_ICON_PREFIXES.some((prefix) => app.icons!.startsWith(prefix))
|
||||
) {
|
||||
return `file://${app.icons}`;
|
||||
}
|
||||
if (!app.category || app.category === "unknown") return "";
|
||||
const arch = window.apm_store.arch || "amd64";
|
||||
const finalArch = app.origin === "spark" ? `${arch}-store` : `${arch}-apm`;
|
||||
@@ -241,21 +368,60 @@ const props = defineProps<{
|
||||
apps: App[];
|
||||
loading: boolean;
|
||||
error: string;
|
||||
activeOrigin: "apm" | "spark";
|
||||
storeFilter: "spark" | "apm" | "both";
|
||||
sparkAvailable: boolean;
|
||||
apmAvailable: boolean;
|
||||
warning: string;
|
||||
loggedIn: boolean;
|
||||
syncing: boolean;
|
||||
syncMessage: string;
|
||||
}>();
|
||||
|
||||
const apmCount = computed(
|
||||
() => props.apps.filter((a) => a.origin === "apm").length,
|
||||
);
|
||||
const sparkCount = computed(
|
||||
() => props.apps.filter((a) => a.origin === "spark").length,
|
||||
);
|
||||
const totalCount = computed(() => props.apps.length);
|
||||
|
||||
// 来源筛选:默认全部;点击统计徽章可在 all/apm/spark 间切换
|
||||
const filterOrigin = ref<"all" | "apm" | "spark">("all");
|
||||
|
||||
// 搜索关键词(按名称/包名不区分大小写过滤已安装应用)
|
||||
const searchQuery = ref("");
|
||||
const filteredApps = computed(() => {
|
||||
// 1. 先按搜索关键词过滤
|
||||
const q = searchQuery.value.trim().toLowerCase();
|
||||
let list = props.apps;
|
||||
if (q) {
|
||||
list = list.filter(
|
||||
(a) =>
|
||||
a.name.toLowerCase().includes(q) ||
|
||||
a.pkgname.toLowerCase().includes(q),
|
||||
);
|
||||
}
|
||||
|
||||
// 2. 再按来源筛选(默认 all = 不过滤)
|
||||
if (filterOrigin.value === "apm") {
|
||||
list = list.filter((a) => a.origin === "apm");
|
||||
} else if (filterOrigin.value === "spark") {
|
||||
list = list.filter((a) => a.origin === "spark");
|
||||
}
|
||||
|
||||
// 3. 排序:APM 应用始终排在前面(默认全部视图也遵守此规则)
|
||||
// 返回新数组,避免修改原始 props.apps
|
||||
return [...list].sort((a, b) => {
|
||||
const aApm = a.origin === "apm" ? 0 : 1;
|
||||
const bApm = b.origin === "apm" ? 0 : 1;
|
||||
if (aApm !== bApm) return aApm - bApm;
|
||||
// 同类内保持原有的字母序,体验更一致
|
||||
return a.pkgname.localeCompare(b.pkgname);
|
||||
});
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const emit = defineEmits<{
|
||||
(e: "close"): void;
|
||||
(e: "refresh"): void;
|
||||
(e: "uninstall", app: App): void;
|
||||
(e: "switch-origin", origin: "apm" | "spark"): void;
|
||||
(e: "open-app", app: App): void;
|
||||
(e: "open-detail", app: App): void;
|
||||
(e: "sync-to-account"): void;
|
||||
@@ -287,16 +453,4 @@ const onOverlayWheel = (e: WheelEvent) => {
|
||||
if (target.closest(".overflow-y-auto, .overflow-auto")) return;
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const sparkEnabled = computed(() => {
|
||||
return props.storeFilter !== "apm" && props.sparkAvailable;
|
||||
});
|
||||
|
||||
const apmEnabled = computed(() => {
|
||||
return props.storeFilter !== "spark" && props.apmAvailable;
|
||||
});
|
||||
|
||||
const showOriginSwitcher = computed(() => {
|
||||
return sparkEnabled.value && apmEnabled.value;
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<div
|
||||
class="h-screen overflow-y-auto bg-slate-50 dark:bg-slate-950 text-slate-900 dark:text-slate-100"
|
||||
class="flex h-screen flex-col overflow-hidden rounded-3xl bg-slate-50 dark:bg-slate-950 text-slate-900 dark:text-slate-100 ring-1 ring-black/5 dark:ring-white/5 shadow-[inset_0_1px_3px_rgba(0,0,0,0.25)]"
|
||||
>
|
||||
<div
|
||||
class="submitter-titlebar sticky top-0 z-30 border-b border-slate-200/70 bg-white px-4 py-3 dark:border-slate-800/70 dark:bg-slate-900"
|
||||
class="submitter-titlebar shrink-0 z-30 border-b border-slate-200/70 bg-white px-4 py-3 dark:border-slate-800/70 dark:bg-slate-900"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
@@ -14,6 +14,15 @@
|
||||
</div>
|
||||
<h1 class="text-lg font-semibold">投稿应用</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="submitter-close-button inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium text-blue-600 hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-slate-800"
|
||||
@click="openSubmissionQueue"
|
||||
>
|
||||
<i class="fas fa-list"></i>
|
||||
查看当前投稿队列
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="submitter-close-button inline-flex h-8 w-8 items-center justify-center rounded-lg text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800"
|
||||
@@ -23,7 +32,9 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto mb-6">
|
||||
<div class="p-6 max-w-2xl mx-auto">
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
@@ -135,6 +146,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2"
|
||||
>官网地址</label
|
||||
>
|
||||
<input
|
||||
v-model="formData.website"
|
||||
type="url"
|
||||
placeholder="https://example.com"
|
||||
class="w-full px-4 py-2.5 rounded-lg border border-slate-200 bg-white dark:bg-slate-800 dark:border-slate-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2"
|
||||
@@ -161,19 +185,6 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2"
|
||||
>官网地址</label
|
||||
>
|
||||
<input
|
||||
v-model="formData.website"
|
||||
type="url"
|
||||
placeholder="https://example.com"
|
||||
class="w-full px-4 py-2.5 rounded-lg border border-slate-200 bg-white dark:bg-slate-800 dark:border-slate-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2"
|
||||
@@ -215,12 +226,16 @@
|
||||
>截图(最多5张)</label
|
||||
>
|
||||
<p class="mb-2 text-xs text-slate-500 dark:text-slate-400">
|
||||
可直接 Ctrl+V 粘贴截图
|
||||
可点击添加、拖放或直接 Ctrl+V 粘贴 PNG 截图
|
||||
</p>
|
||||
<div
|
||||
tabindex="0"
|
||||
class="grid grid-cols-5 gap-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
class="grid grid-cols-5 gap-3 rounded-lg border-2 border-dashed border-transparent p-2 transition-colors hover:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
@paste="handleScreenshotPaste"
|
||||
@drop="handleScreenshotDrop"
|
||||
@dragover.prevent
|
||||
@dragenter.prevent
|
||||
@dragleave.prevent
|
||||
>
|
||||
<div
|
||||
v-for="(screenshot, index) in formData.screenshots"
|
||||
@@ -247,11 +262,17 @@
|
||||
<input
|
||||
ref="screenshotInput"
|
||||
type="file"
|
||||
accept=".png,.jpg,.jpeg"
|
||||
accept=".png"
|
||||
multiple
|
||||
class="hidden"
|
||||
@change="handleScreenshotSelect"
|
||||
/>
|
||||
<p
|
||||
v-if="mediaError"
|
||||
class="mt-2 text-sm text-red-600 dark:text-red-400"
|
||||
>
|
||||
{{ mediaError }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -536,7 +557,7 @@
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 px-4 py-2 rounded-lg border border-slate-200 bg-white text-slate-700 dark:bg-slate-800 dark:border-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors"
|
||||
@click="resetFormAndClose"
|
||||
@click="continueSubmission"
|
||||
>
|
||||
继续投递
|
||||
</button>
|
||||
@@ -587,7 +608,7 @@
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 bg-black/50"
|
||||
class="absolute inset-0 bg-black/50 rounded-3xl"
|
||||
@click="showArchPackDialog = false"
|
||||
></div>
|
||||
<div
|
||||
@@ -620,11 +641,25 @@
|
||||
class="w-full p-4 rounded-lg border-2 border-slate-200 dark:border-slate-700 hover:border-emerald-500 transition-colors text-left"
|
||||
@click="selectPackArch(arch)"
|
||||
>
|
||||
<div class="font-medium text-slate-900 dark:text-slate-100">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs font-medium"
|
||||
:class="
|
||||
isSparkHistoryStore(arch.store)
|
||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
|
||||
: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300'
|
||||
"
|
||||
>
|
||||
{{ isSparkHistoryStore(arch.store) ? "Spark" : "APM" }}
|
||||
</span>
|
||||
<span class="font-medium text-slate-900 dark:text-slate-100">
|
||||
{{ getArchDisplayName(arch.store) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-sm text-slate-500 dark:text-slate-400 mt-1">
|
||||
输出: {{ formData.pkgname }}-{{ arch.store }}.tar.gz
|
||||
输出: {{ formData.pkgname.toLowerCase() }}-{{
|
||||
arch.store
|
||||
}}.tar.gz
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
@@ -649,7 +684,7 @@
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 bg-black/50"
|
||||
class="absolute inset-0 bg-black/50 rounded-3xl"
|
||||
@click="showArchDialog = false"
|
||||
></div>
|
||||
<div
|
||||
@@ -676,14 +711,23 @@
|
||||
|
||||
<div class="space-y-3 mb-6">
|
||||
<button
|
||||
v-for="arch in availableArchs"
|
||||
v-for="arch in sortedArchs"
|
||||
:key="arch.store"
|
||||
type="button"
|
||||
class="w-full p-4 rounded-lg border-2 border-slate-200 dark:border-slate-700 hover:border-blue-500 transition-colors text-left"
|
||||
class="w-full p-4 rounded-lg border-2 transition-colors text-left"
|
||||
:class="archOriginBorderClass(arch.store)"
|
||||
@click="selectArch(arch)"
|
||||
>
|
||||
<div class="font-medium text-slate-900 dark:text-slate-100">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-slate-900 dark:text-slate-100">
|
||||
{{ getArchDisplayName(arch.store) }}
|
||||
</span>
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium"
|
||||
:class="getOriginBadgeClass(arch.store)"
|
||||
>
|
||||
{{ getOriginLabel(arch.store) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-sm text-slate-500 dark:text-slate-400 mt-1">
|
||||
版本: {{ arch.version }} | 分类: {{ arch.category }}
|
||||
@@ -704,6 +748,7 @@
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -767,6 +812,7 @@ const isSearchingHistory = ref(false);
|
||||
const showArchDialog = ref(false);
|
||||
const availableArchs = ref<HistoryArchInfo[]>([]);
|
||||
const currentDebArch = ref("");
|
||||
const mediaError = ref("");
|
||||
const iconPreview = ref("");
|
||||
const iconFileName = ref("");
|
||||
|
||||
@@ -822,6 +868,23 @@ const isValidUrl = (url: string): boolean => {
|
||||
}
|
||||
};
|
||||
|
||||
const getContributorFromGit = async (): Promise<string> => {
|
||||
try {
|
||||
const nameResult = await window.ipcRenderer.invoke("get-git-name");
|
||||
const emailResult = await window.ipcRenderer.invoke("get-git-email");
|
||||
|
||||
const name = nameResult?.success ? nameResult.data : "";
|
||||
const email = emailResult?.success ? emailResult.data : "";
|
||||
|
||||
if (name && email) {
|
||||
return `${name} <${email}>`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[Submitter] Failed to get git config:", error);
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const isFormValid = computed(() => {
|
||||
const hasRequiredFields =
|
||||
formData.name.trim() &&
|
||||
@@ -864,13 +927,70 @@ const hasUserStartedFilling = computed(() => {
|
||||
);
|
||||
});
|
||||
|
||||
const getArchDisplayName = (store: string): string => {
|
||||
const archMap: Record<string, string> = {
|
||||
store: "AMD64 (x86_64)",
|
||||
"aarch64-store": "ARM64 (aarch64)",
|
||||
"loong64-store": "LoongArch64",
|
||||
const getHistoryArch = (
|
||||
store: string,
|
||||
): "amd64" | "arm64" | "loong64" | "other" => {
|
||||
if (store === "store" || store.startsWith("amd64-")) return "amd64";
|
||||
if (store === "aarch64-store" || store.startsWith("arm64-")) return "arm64";
|
||||
if (store === "loong64-store" || store.startsWith("loong64-"))
|
||||
return "loong64";
|
||||
return "other";
|
||||
};
|
||||
|
||||
const isSparkHistoryStore = (store: string): boolean => {
|
||||
// 兼容新格式(amd64-store / arm64-store / loong64-store / amd64-apm ...)
|
||||
// 与旧格式(store / aarch64-store / loong64-store)
|
||||
return store === "store" || store.endsWith("-store");
|
||||
};
|
||||
|
||||
const sortHistoryArchs = (archs: HistoryArchInfo[]): HistoryArchInfo[] => {
|
||||
const archOrder: Record<ReturnType<typeof getHistoryArch>, number> = {
|
||||
amd64: 0,
|
||||
arm64: 1,
|
||||
loong64: 2,
|
||||
other: 3,
|
||||
};
|
||||
return archMap[store] || store;
|
||||
|
||||
return [...archs].sort((a, b) => {
|
||||
const sourceOrder =
|
||||
Number(isSparkHistoryStore(b.store)) -
|
||||
Number(isSparkHistoryStore(a.store));
|
||||
if (sourceOrder !== 0) return sourceOrder;
|
||||
return (
|
||||
archOrder[getHistoryArch(a.store)] - archOrder[getHistoryArch(b.store)]
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const getArchDisplayName = (store: string): string => {
|
||||
const arch = getHistoryArch(store);
|
||||
const archMap: Record<typeof arch, string> = {
|
||||
amd64: "AMD64 (x86_64)",
|
||||
arm64: "ARM64 (aarch64)",
|
||||
loong64: "LoongArch64",
|
||||
other: store,
|
||||
};
|
||||
return archMap[arch];
|
||||
};
|
||||
|
||||
const sortedArchs = computed(() => sortHistoryArchs(availableArchs.value));
|
||||
|
||||
const getOriginLabel = (store: string): string => {
|
||||
return isSparkHistoryStore(store) ? "Spark" : "APM";
|
||||
};
|
||||
|
||||
const getOriginBadgeClass = (store: string): string => {
|
||||
if (isSparkHistoryStore(store)) {
|
||||
return "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400";
|
||||
}
|
||||
return "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400";
|
||||
};
|
||||
|
||||
const archOriginBorderClass = (store: string): string => {
|
||||
if (isSparkHistoryStore(store)) {
|
||||
return "border-slate-200 dark:border-slate-700 hover:border-blue-500";
|
||||
}
|
||||
return "border-slate-200 dark:border-slate-700 hover:border-amber-400";
|
||||
};
|
||||
|
||||
const loadCategoriesPromise = ref<Promise<void> | null>(null);
|
||||
@@ -1042,6 +1162,14 @@ const updateFormTags = () => {
|
||||
formData.tags = selectedTags.value.map((t) => t.value).join(";");
|
||||
};
|
||||
|
||||
const openSubmissionQueue = (): void => {
|
||||
window.open(
|
||||
"https://upload.spark-app.store/",
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
};
|
||||
|
||||
const selectDebFile = async () => {
|
||||
const result = await window.ipcRenderer.invoke("select-deb-file");
|
||||
if (result?.success && result.filePath) {
|
||||
@@ -1091,14 +1219,8 @@ const searchHistoryApp = async () => {
|
||||
console.log("[Submitter] availableArchs before:", availableArchs.value);
|
||||
console.log("[Submitter] showArchDialog before:", showArchDialog.value);
|
||||
|
||||
// 按 amd64 → arm64 → loong64 顺序排序
|
||||
const archOrder: Record<string, number> = {
|
||||
store: 0,
|
||||
"aarch64-store": 1,
|
||||
"loong64-store": 2,
|
||||
};
|
||||
availableArchs.value = [...historyResult.data].sort(
|
||||
(a, b) => (archOrder[a.store] ?? 99) - (archOrder[b.store] ?? 99),
|
||||
availableArchs.value = sortHistoryArchs(
|
||||
historyResult.data as HistoryArchInfo[],
|
||||
);
|
||||
console.log("[Submitter] availableArchs after:", availableArchs.value);
|
||||
console.log(
|
||||
@@ -1112,7 +1234,7 @@ const searchHistoryApp = async () => {
|
||||
}
|
||||
|
||||
// 从第一条历史记录预填名称和分类
|
||||
const firstArch = historyResult.data[0];
|
||||
const firstArch = availableArchs.value[0];
|
||||
if (firstArch) {
|
||||
formData.name = firstArch.name || formData.name;
|
||||
formData.category = firstArch.category || formData.category;
|
||||
@@ -1183,6 +1305,15 @@ const searchHistoryApp = async () => {
|
||||
const parseDebFileAndSearchHistory = async (debPath: string) => {
|
||||
isParsingDeb.value = true;
|
||||
debParseError.value = "";
|
||||
availableArchs.value = [];
|
||||
showArchDialog.value = false;
|
||||
formData.pkgname = "";
|
||||
formData.version = "";
|
||||
formData.author = "";
|
||||
formData.contributor = "";
|
||||
formData.website = "";
|
||||
formData.description = "";
|
||||
currentDebArch.value = "";
|
||||
|
||||
try {
|
||||
console.log(
|
||||
@@ -1231,6 +1362,12 @@ const parseDebFileAndSearchHistory = async (debPath: string) => {
|
||||
JSON.stringify(formData, null, 2),
|
||||
);
|
||||
|
||||
const gitContributor = await getContributorFromGit();
|
||||
if (gitContributor) {
|
||||
formData.contributor = gitContributor;
|
||||
console.log("[Submitter] Contributor updated from git:", gitContributor);
|
||||
}
|
||||
|
||||
if (formData.pkgname) {
|
||||
isSearchingHistory.value = true;
|
||||
try {
|
||||
@@ -1294,13 +1431,20 @@ const handleDrop = async (event: DragEvent) => {
|
||||
if (file.name.endsWith(".deb")) {
|
||||
console.log("[Submitter] File is a deb package");
|
||||
|
||||
// 在 contextIsolation 环境下,File.path 不可用
|
||||
// 使用 Electron 的 webUtils.getPathForFile() 获取真实文件系统路径
|
||||
let filePath: string;
|
||||
try {
|
||||
filePath = window.electronUtils.getPathForFile(file);
|
||||
console.log("[Submitter] File path from electronUtils:", filePath);
|
||||
} catch {
|
||||
console.warn(
|
||||
"[Submitter] electronUtils.getPathForFile failed, trying fallback",
|
||||
);
|
||||
const textUriList = event.dataTransfer?.getData("text/uri-list");
|
||||
console.log("[Submitter] text/uri-list:", textUriList);
|
||||
|
||||
const textPlain = event.dataTransfer?.getData("text/plain");
|
||||
console.log("[Submitter] text/plain:", textPlain);
|
||||
|
||||
const filePath = file.path || textUriList || textPlain;
|
||||
filePath = textUriList || textPlain || "";
|
||||
}
|
||||
console.log("[Submitter] Final filePath:", filePath);
|
||||
|
||||
if (filePath) {
|
||||
@@ -1339,7 +1483,9 @@ const selectArch = async (arch: HistoryArchInfo) => {
|
||||
|
||||
formData.name = arch.name || formData.name;
|
||||
formData.author = arch.author || formData.author;
|
||||
formData.contributor = arch.contributor || formData.contributor;
|
||||
if (!formData.contributor) {
|
||||
formData.contributor = arch.contributor || "";
|
||||
}
|
||||
formData.website = arch.website || formData.website;
|
||||
formData.category = arch.category || formData.category;
|
||||
formData.description = arch.more || formData.description;
|
||||
@@ -1397,10 +1543,13 @@ const selectIconFile = () => {
|
||||
iconFileInput.value?.click();
|
||||
};
|
||||
|
||||
const handleIconFileSelect = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const file = target.files?.[0];
|
||||
if (file) {
|
||||
const isPngFile = (file: File): boolean => {
|
||||
return (
|
||||
file.type === "image/png" || (file.type === "" && /\.png$/i.test(file.name))
|
||||
);
|
||||
};
|
||||
|
||||
const readIconFile = (file: File): void => {
|
||||
iconFileName.value = file.name;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
@@ -1409,26 +1558,31 @@ const handleIconFileSelect = (event: Event) => {
|
||||
iconPreview.value = dataUrl;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const handleIconFileSelect = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const file = target.files?.[0];
|
||||
if (file) {
|
||||
if (isPngFile(file)) {
|
||||
mediaError.value = "";
|
||||
readIconFile(file);
|
||||
} else {
|
||||
mediaError.value = "应用图标仅支持 PNG 格式。";
|
||||
}
|
||||
}
|
||||
target.value = "";
|
||||
};
|
||||
|
||||
const handleIconDrop = (event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
const file = event.dataTransfer?.files?.[0];
|
||||
if (
|
||||
file &&
|
||||
(file.name.endsWith(".png") ||
|
||||
file.name.endsWith(".jpg") ||
|
||||
file.name.endsWith(".jpeg"))
|
||||
) {
|
||||
iconFileName.value = file.name;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const dataUrl = e.target?.result as string;
|
||||
formData.iconPath = dataUrl;
|
||||
iconPreview.value = dataUrl;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
if (!file) return;
|
||||
if (isPngFile(file)) {
|
||||
mediaError.value = "";
|
||||
readIconFile(file);
|
||||
} else {
|
||||
mediaError.value = "应用图标仅支持 PNG 格式。";
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1437,7 +1591,14 @@ const addScreenshot = () => {
|
||||
};
|
||||
|
||||
const importScreenshots = (files: File[]) => {
|
||||
for (const file of files) {
|
||||
const pngFiles = files.filter(isPngFile);
|
||||
if (pngFiles.length !== files.length) {
|
||||
mediaError.value = "截图仅支持 PNG 格式。";
|
||||
} else if (pngFiles.length > 0) {
|
||||
mediaError.value = "";
|
||||
}
|
||||
|
||||
for (const file of pngFiles) {
|
||||
if (formData.screenshots.length >= 5) break;
|
||||
|
||||
const reader = new FileReader();
|
||||
@@ -1455,16 +1616,18 @@ const importScreenshots = (files: File[]) => {
|
||||
|
||||
const handleScreenshotSelect = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const files = Array.from(target.files ?? []).filter((file) =>
|
||||
/\.(png|jpe?g)$/i.test(file.name),
|
||||
);
|
||||
importScreenshots(files);
|
||||
importScreenshots(Array.from(target.files ?? []));
|
||||
target.value = "";
|
||||
};
|
||||
|
||||
const handleScreenshotDrop = (event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
importScreenshots(Array.from(event.dataTransfer?.files ?? []));
|
||||
};
|
||||
|
||||
const handleScreenshotPaste = (event: ClipboardEvent) => {
|
||||
const files = Array.from(event.clipboardData?.items ?? [])
|
||||
.filter((item) => item.type.startsWith("image/"))
|
||||
.filter((item) => item.kind === "file")
|
||||
.map((item) => item.getAsFile())
|
||||
.filter((file): file is File => file !== null);
|
||||
|
||||
@@ -1497,6 +1660,7 @@ const resetForm = () => {
|
||||
submitSuccess.value = false;
|
||||
submitError.value = "";
|
||||
debParseError.value = "";
|
||||
mediaError.value = "";
|
||||
packageSuccess.value = false;
|
||||
packageError.value = "";
|
||||
packageResult.value = null;
|
||||
@@ -1514,14 +1678,29 @@ const resetForm = () => {
|
||||
isSearchingHistory.value = false;
|
||||
};
|
||||
|
||||
const closeSubmitSuccessModal = () => {
|
||||
const clearSubmitTransientState = (): void => {
|
||||
showSubmitSuccessModal.value = false;
|
||||
resetForm();
|
||||
submitSuccess.value = false;
|
||||
isSubmitting.value = false;
|
||||
uploadProgress.value = 0;
|
||||
uploadStage.value = "";
|
||||
uploadStageMessage.value = "";
|
||||
uploadStages.value = [];
|
||||
};
|
||||
|
||||
const resetFormAndClose = () => {
|
||||
showSubmitSuccessModal.value = false;
|
||||
const closeSubmitSuccessModal = () => {
|
||||
clearSubmitTransientState();
|
||||
};
|
||||
|
||||
const continueSubmission = () => {
|
||||
const shouldClear = window.confirm(
|
||||
"是否清空当前表单内容后继续投递?选择“取消”将保留已填写内容。",
|
||||
);
|
||||
if (shouldClear) {
|
||||
resetForm();
|
||||
} else {
|
||||
clearSubmitTransientState();
|
||||
}
|
||||
};
|
||||
|
||||
const submitForm = async () => {
|
||||
@@ -1712,26 +1891,64 @@ const closeWindow = () => {
|
||||
window.ipcRenderer.send("close-submitter-window");
|
||||
};
|
||||
|
||||
import { onMounted, nextTick } from "vue";
|
||||
import { onMounted, onUnmounted, nextTick } from "vue";
|
||||
|
||||
const getGitEmail = async () => {
|
||||
const getGitInfo = async () => {
|
||||
try {
|
||||
const result = await window.ipcRenderer.invoke("get-git-email");
|
||||
if (result?.success && result.data) {
|
||||
formData.mail = result.data;
|
||||
console.log("[Submitter] Git email auto-filled:", result.data);
|
||||
const [nameResult, emailResult] = await Promise.all([
|
||||
window.ipcRenderer.invoke("get-git-name"),
|
||||
window.ipcRenderer.invoke("get-git-email"),
|
||||
]);
|
||||
|
||||
let gitName = "";
|
||||
let gitEmail = "";
|
||||
|
||||
if (nameResult?.success && nameResult.data) {
|
||||
gitName = nameResult.data;
|
||||
console.log("[Submitter] Git name auto-filled:", gitName);
|
||||
}
|
||||
|
||||
if (emailResult?.success && emailResult.data) {
|
||||
gitEmail = emailResult.data;
|
||||
console.log("[Submitter] Git email auto-filled:", gitEmail);
|
||||
}
|
||||
|
||||
// 将 git name 和 email 合并填入 contributor 字段,格式: Name <email>
|
||||
if (gitName || gitEmail) {
|
||||
if (gitName && gitEmail) {
|
||||
formData.contributor = `${gitName} <${gitEmail}>`;
|
||||
} else if (gitName) {
|
||||
formData.contributor = gitName;
|
||||
} else if (gitEmail) {
|
||||
formData.contributor = gitEmail;
|
||||
}
|
||||
}
|
||||
|
||||
// 单独填入邮箱
|
||||
if (gitEmail) {
|
||||
formData.mail = gitEmail;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[Submitter] Failed to get git email:", err);
|
||||
console.warn("[Submitter] Failed to get git info:", err);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
console.log("[Submitter] Component mounted, loading categories and tags");
|
||||
// 透明窗口:让 body/html 背景透明,圆角才能透出,否则四角是方角。
|
||||
// 保存原始值,在 onUnmounted 中恢复,避免副作用泄漏到其它视图。
|
||||
const prevHtmlBg = document.documentElement.style.backgroundColor;
|
||||
const prevBodyBg = document.body.style.backgroundColor;
|
||||
document.documentElement.style.backgroundColor = "transparent";
|
||||
document.body.style.backgroundColor = "transparent";
|
||||
onUnmounted(() => {
|
||||
document.documentElement.style.backgroundColor = prevHtmlBg;
|
||||
document.body.style.backgroundColor = prevBodyBg;
|
||||
});
|
||||
// 先等待分类列表加载完成,避免后续竞态
|
||||
await Promise.all([loadCategoriesList(), loadTagsList()]);
|
||||
// 尝试从 git 配置读取邮箱
|
||||
await getGitEmail();
|
||||
// 尝试从 git 配置读取 name 和 email,填入 contributor 和 mail
|
||||
await getGitInfo();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
class="window-titlebar sticky top-0 z-20 flex h-10 shrink-0 items-center justify-between border-b border-slate-200/70 bg-white px-3 text-slate-700 dark:border-slate-800/70 dark:bg-slate-950 dark:text-slate-200"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="h-3 w-3 rounded-full bg-[#2b7fff]"></span>
|
||||
<img
|
||||
src="../assets/imgs/spark-store.svg"
|
||||
class="h-5 w-5 flex-shrink-0"
|
||||
alt="星火应用商店"
|
||||
/>
|
||||
<span class="truncate text-sm font-semibold">星火应用商店</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -61,11 +61,13 @@
|
||||
|
||||
<label class="block relative">
|
||||
<span class="sr-only">搜索更新</span>
|
||||
<i
|
||||
class="fas fa-search pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-slate-400"
|
||||
></i>
|
||||
<input
|
||||
:value="searchQuery"
|
||||
type="search"
|
||||
placeholder="搜索应用或包名"
|
||||
class="w-full rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-brand/60 focus:bg-white dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100 dark:focus:bg-slate-900"
|
||||
class="w-full rounded-2xl border border-slate-200/70 bg-white/80 py-3 pl-12 pr-20 text-sm text-slate-700 shadow-sm outline-none transition placeholder:text-slate-400 focus:border-brand/50 focus:ring-4 focus:ring-brand/10 dark:border-slate-800/70 dark:bg-slate-900/60 dark:text-slate-200"
|
||||
@input="handleInput"
|
||||
/>
|
||||
<button
|
||||
|
||||
Vendored
+3
@@ -22,6 +22,9 @@ declare global {
|
||||
};
|
||||
windowControls: WindowControlBridge;
|
||||
updateCenter: UpdateCenterBridge;
|
||||
electronUtils: {
|
||||
getPathForFile: (file: File) => string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+5
-5
@@ -16,11 +16,11 @@ load_transhell
|
||||
if [[ "$IS_APM_ENV" = "" ]] ;then
|
||||
case $(arch) in
|
||||
x86_64 | i686 | i386)
|
||||
STORE_URL="store"
|
||||
STORE_URL="amd64-store"
|
||||
STORE_LIST_URL=""
|
||||
;;
|
||||
aarch64)
|
||||
STORE_URL="aarch64-store"
|
||||
STORE_URL="arm64-store"
|
||||
STORE_LIST_URL="-aarch64"
|
||||
;;
|
||||
loongarch64)
|
||||
@@ -62,13 +62,13 @@ is_empty_dir(){
|
||||
}
|
||||
|
||||
function update_list(){
|
||||
curl --progress-bar -o /opt/durapps/spark-store/bin/apt-fast-conf/sources.list.d/aptss.list "${SPARK_DOWNLOAD_SERVER_URL}/${UPSTREAM_CATOGARY}${STORE_LIST_URL}.list"
|
||||
curl -L --progress-bar -o /opt/durapps/spark-store/bin/apt-fast-conf/sources.list.d/aptss.list "${SPARK_DOWNLOAD_SERVER_URL}/${UPSTREAM_CATOGARY}${STORE_LIST_URL}.list"
|
||||
log.info "${UPSTREAM_CATOGARY}${STORE_LIST_URL}.list update done"
|
||||
}
|
||||
|
||||
function update_conf(){
|
||||
mkdir -p /tmp/aptss-conf/
|
||||
curl --progress-bar -o /tmp/aptss-conf/apt-fast.conf "${SPARK_DOWNLOAD_SERVER_URL}/apt-fast.conf"
|
||||
curl -L --progress-bar -o /tmp/aptss-conf/apt-fast.conf "${SPARK_DOWNLOAD_SERVER_URL}/apt-fast.conf"
|
||||
log.info "apt-fast.conf update done"
|
||||
chmod -R 755 /tmp/aptss-conf
|
||||
}
|
||||
@@ -96,7 +96,7 @@ update_conf
|
||||
fi
|
||||
|
||||
|
||||
if [ ! -e "/var/lib/aptss/lists/${SPARK_DOWNLOAD_SERVER_URL_NO_PROTOCOL}_${STORE_URL}_Packages" ] && [ ! -e "/var/lib/aptss/lists/d.store.deepinos.org.cn_${STORE_URL}_Packages" ] && [ ! -e "/var/lib/aptss/lists/mirrors.sdu.edu.cn_spark-store-repository_${STORE_URL}_Packages" ];then
|
||||
if [ ! -e "/var/lib/aptss/lists/${SPARK_DOWNLOAD_SERVER_URL_NO_PROTOCOL}_${STORE_URL}_Packages" ] && [ ! -e "/var/lib/aptss/lists/${SPARK_DOWNLOAD_SERVER_URL_NO_PROTOCOL}_aarch64-store_Packages" ] && [ ! -e "/var/lib/aptss/lists/${SPARK_DOWNLOAD_SERVER_URL_NO_PROTOCOL}_store_Packages" ];then
|
||||
|
||||
mkdir -p /tmp/aptss-conf/
|
||||
echo -e "\e[1;32m${TRANSHELL_CONTENT_GETTING_SERVER_CONFIG_AND_MIRROR_LIST}\e[0m"
|
||||
|
||||
@@ -29,6 +29,16 @@ if grep -q "ID=aosc" /etc/os-release; then
|
||||
ARGS="$ARGS --no-spark"
|
||||
fi
|
||||
|
||||
# 检查龙GPU,添加 --disable-gpu
|
||||
ARCH=$(uname -m)
|
||||
if [ "$ARCH" = "loongarch64" ] || [ "$ARCH" = "loong64" ]; then
|
||||
is_loonggpu=$(lspci -s $(basename $(readlink $(grep -l connected /sys/class/drm/card*/*/status 2>/dev/null | head -1 | grep -o 'card[0-9]*' | xargs -I{} echo /sys/class/drm/{}/device))) | grep -qi loongson && echo "Found" || echo "NotFound")
|
||||
if [ "$is_loonggpu" = "Found" ]; then
|
||||
echo "检测到龙GPU"
|
||||
ARGS="$ARGS --disable-gpu"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 注意:已移除原先针对 arm64 + wayland 添加 --disable-gpu 的逻辑,
|
||||
# 现在 arm64 设备无论是否使用 wayland 均不再添加此参数。
|
||||
|
||||
+8
-5
@@ -62,11 +62,11 @@ export DEBIAN_FRONTEND=noninteractive
|
||||
if [[ "$IS_APM_ENV" = "" ]] ;then
|
||||
case $(arch) in
|
||||
x86_64 | i686 | i386)
|
||||
STORE_URL="store"
|
||||
STORE_URL="amd64-store"
|
||||
STORE_LIST_URL=""
|
||||
;;
|
||||
aarch64)
|
||||
STORE_URL="aarch64-store"
|
||||
STORE_URL="arm64-store"
|
||||
STORE_LIST_URL="-aarch64"
|
||||
;;
|
||||
loongarch64)
|
||||
@@ -195,10 +195,13 @@ function hash_check() {
|
||||
# 检查可能的仓库位置
|
||||
if [ -e "/var/lib/aptss/lists/${SPARK_DOWNLOAD_SERVER_URL_NO_PROTOCOL}_${STORE_URL}_Packages" ]; then
|
||||
PACKAGES_DATA_PATH="/var/lib/aptss/lists/${SPARK_DOWNLOAD_SERVER_URL_NO_PROTOCOL}_${STORE_URL}_Packages"
|
||||
elif [ -e "/var/lib/aptss/lists/d.store.deepinos.org.cn_${STORE_URL}_Packages" ]; then
|
||||
PACKAGES_DATA_PATH="/var/lib/aptss/lists/d.store.deepinos.org.cn_${STORE_URL}_Packages"
|
||||
elif [ -e "/var/lib/aptss/lists/${SPARK_DOWNLOAD_SERVER_URL_NO_PROTOCOL}_store_Packages" ]; then
|
||||
PACKAGES_DATA_PATH="/var/lib/aptss/lists/${SPARK_DOWNLOAD_SERVER_URL_NO_PROTOCOL}_store_Packages"
|
||||
elif [ -e "/var/lib/aptss/lists/${SPARK_DOWNLOAD_SERVER_URL_NO_PROTOCOL}_aarch64-store_Packages" ]; then
|
||||
PACKAGES_DATA_PATH="/var/lib/aptss/lists/${SPARK_DOWNLOAD_SERVER_URL_NO_PROTOCOL}_aarch64-store_Packages"
|
||||
else
|
||||
PACKAGES_DATA_PATH="/var/lib/aptss/lists/mirrors.sdu.edu.cn_spark-store-repository_${STORE_URL}_Packages"
|
||||
echo "ERROR:NO_PACKAGE_FILE_FOUND!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "正在运行包验证..."
|
||||
|
||||
Reference in New Issue
Block a user