Compare commits

...

22 Commits

Author SHA1 Message Date
momen f83f8f6d80 fix(下载队列): 避免更新任务覆盖安装状态 2026-04-13 12:57:24 +08:00
shenmo7192 763af5c37e feat(update-center): 添加全选功能及状态管理
添加全选复选框组件及相关状态管理逻辑
实现全选/取消全选功能
添加部分选中状态显示
更新工具栏组件以支持新功能
2026-04-12 22:02:01 +08:00
shenmo7192 ca7520cb2e fix(modal): 修复模态框滚动和点击事件处理
为多个模态框组件添加滚动和点击事件处理,防止背景滚动时内容滚动
当点击模态框背景时关闭模态框,同时阻止事件冒泡到内容区域
2026-04-12 21:34:51 +08:00
shenmo7192 ba10f90dde fix(滚动): 为多个组件添加overscroll-contain并处理滚轮事件
为多个模态框和列表组件添加overscroll-contain类以防止滚动链
添加处理函数阻止模态框背景的滚轮事件传播到内容区域
2026-04-12 21:30:33 +08:00
shenmo7192 a280d44481 fix(下载队列): 为滚动容器添加overscroll-contain防止滚动溢出
refactor(安装管理器): 移除upgradeOnly逻辑并统一使用ssinstall命令

test(更新中心): 更新测试用例以匹配新的安装逻辑
2026-04-12 21:25:10 +08:00
shenmo7192 9244708b90 feat(安装管理器): 添加安装参数以禁用桌面快捷方式和使用原生安装
添加 --no-create-desktop-entry 和 --native 参数以避免创建桌面快捷方式并确保使用原生安装方式
2026-04-12 21:14:06 +08:00
shenmo7192 c46bb03e3f fix: 将host-spawn替换为systemd-run --user以提升兼容性 2026-04-12 21:01:33 +08:00
momen 71db2f2b71 fix: 现在软件管理器滚到底部后应用列表不会透滚 2026-04-12 19:44:54 +08:00
momen 67aa83fe26 update 修复更新工具缺少软件名检查的问题 2026-04-12 19:39:17 +08:00
momen 60628ff1fa test: 支持更新下载时展示图标 2026-04-12 19:11:11 +08:00
shenmo7192 81cd00661c docs: 添加项目主页链接到package.json 2026-04-12 19:04:10 +08:00
shenmo7192 5ebbf8c223 build: 更新electron构建配置和vite别名设置
更新electron-builder.yml中的linux平台图标配置
在vite.config.ts中添加路径别名解析配置
2026-04-12 19:03:56 +08:00
momen 68ab999eed update 尝试修复点击更新后无法推送到下载列表的问题 2026-04-12 19:00:14 +08:00
momen 9080d76575 docs(update-center): add implementation notes 2026-04-12 18:00:06 +08:00
shenmo7192 e2f59b3cdf 复用下载中心(1/2) 2026-04-12 17:53:16 +08:00
shenmo7192 6fcfa438d9 feat(update-center): 统一使用下载包文件进行安装
- 移除 buildLegacySparkUpgradeCommand,所有更新现在需要先下载 deb 包
- 为 APTSS 添加元数据查询功能
- 优化 aria2c 下载参数,使用 metalink URL
- 版本号更新至 5.0.0beta4
2026-04-12 16:44:55 +08:00
momen fa2689c753 feat(search): prioritize app name matches 2026-04-11 16:47:51 +08:00
shenmo7192 7bf2a5c55b beta4 集成更新器
Signed-off-by: shenmo <jifengshenmo@outlook.com>
2026-04-11 08:34:00 +00:00
momen 62c1e51223 chore: 移除并重新添加 engines 字段到 package-lock.jsonm,就解决风险插件问题 2026-04-11 14:26:55 +08:00
momen a4a2ec4216 chore(deps): secure electron builder toolchain
Refresh the Loong electron-builder stack to clear npm audit findings and declare the Node.js floor required by the updated build tooling.
2026-04-11 14:13:32 +08:00
momen a513c81606 !382 fix(update-center): cascade local and remote icon fallbacks
Merge pull request !382 from momen/update-center-icon-fallback
2026-04-11 03:55:27 +00:00
shenmo7192 bcef173049 !381 feat(滚动): 添加分类切换时重置虚拟滚动位置功能
Merge pull request !381 from momen/feat-electron-update-center
2026-04-10 09:28:47 +00:00
44 changed files with 3600 additions and 2922 deletions
+2 -1
View File
@@ -38,4 +38,5 @@ pnpm-lock.yaml
yarn.lock yarn.lock
.lock .lock
test-results.json test-results.json
.worktrees/
+1 -1
View File
@@ -12,7 +12,7 @@
### 系统要求 ### 系统要求
- **Node.js:** >= 20.x - **Node.js:** >= 22.12.0
- **npm:** >= 9.x 或 pnpm >= 8.x - **npm:** >= 9.x 或 pnpm >= 8.x
- **操作系统:** Linux(推荐 Ubuntu 22.04+ - **操作系统:** Linux(推荐 Ubuntu 22.04+
- **可选:** APM 包管理器(用于测试) - **可选:** APM 包管理器(用于测试)
@@ -0,0 +1,801 @@
# Update Center Icon Fallback Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Change Electron update-center icons to load in the order `localIcon -> remoteIcon -> placeholder`, so a successful local icon never triggers remote/default loading, but failed local loads still fall through to the remote icon.
**Architecture:** Split the current single `icon` field into two explicit sources resolved in the main process: `localIcon` and `remoteIcon`. Keep URL/path resolution in `electron/main/backend/update-center/icons.ts`, pass both fields through the service snapshot, and let `UpdateCenterItem.vue` own the runtime fallback state when `img` emits `error`.
**Tech Stack:** Electron main process, Node.js `fs`/`path`, Vue 3 `<script setup>`, TypeScript strict mode, Vitest, Testing Library Vue.
---
## File Map
- Modify: `electron/main/backend/update-center/types.ts` - replace the single update-center icon field with `localIcon` and `remoteIcon`.
- Modify: `electron/main/backend/update-center/icons.ts` - keep local/remote resolution helpers and return both candidates via `resolveUpdateItemIcons()`.
- Modify: `electron/main/backend/update-center/index.ts` - enrich loaded update items with the two icon fields instead of one final `icon`.
- Modify: `electron/main/backend/update-center/service.ts` - expose `localIcon` and `remoteIcon` to renderer item/task snapshots.
- Modify: `src/global/typedefinition.ts` - update renderer-facing update-center item/task types.
- Modify: `src/components/update-center/UpdateCenterItem.vue` - render the current icon candidate and advance from local to remote to placeholder on load failures.
- Modify: `src/__tests__/unit/update-center/icons.test.ts` - verify icon helper output is now `{ localIcon?, remoteIcon? }`.
- Modify: `src/__tests__/unit/update-center/load-items.test.ts` - verify loaded items receive `remoteIcon` instead of the old `icon` field.
- Modify: `src/__tests__/unit/update-center/registerUpdateCenter.test.ts` - verify service task snapshots preserve both icon fields.
- Modify: `src/__tests__/unit/update-center/UpdateCenterItem.test.ts` - verify the renderer fallback order.
### Task 1: Split Backend Icon Resolution Into Local And Remote Sources
**Files:**
- Modify: `electron/main/backend/update-center/types.ts`
- Modify: `electron/main/backend/update-center/icons.ts`
- Test: `src/__tests__/unit/update-center/icons.test.ts`
- [ ] **Step 1: Write the failing test**
Replace the single-icon assertions in `src/__tests__/unit/update-center/icons.test.ts` with these four tests:
```ts
it("returns both localIcon and remoteIcon when an aptss desktop icon resolves", async () => {
const pkgname = "spark-weather";
const applicationsDirectory = "/usr/share/applications";
const desktopPath = `${applicationsDirectory}/weather-launcher.desktop`;
const iconPath = `/usr/share/pixmaps/${pkgname}.png`;
const { resolveUpdateItemIcons } = await loadIconsModule({
directories: {
[applicationsDirectory]: ["weather-launcher.desktop"],
},
files: {
[desktopPath]: `[Desktop Entry]\nName=Spark Weather\nIcon=${iconPath}\n`,
[iconPath]: "png",
},
packageFiles: {
[pkgname]: [desktopPath],
},
});
expect(
resolveUpdateItemIcons({
pkgname,
source: "aptss",
currentVersion: "1.0.0",
nextVersion: "2.0.0",
category: "tools",
arch: "amd64",
}),
).toEqual({
localIcon: iconPath,
remoteIcon:
"https://erotica.spark-app.store/amd64-store/tools/spark-weather/icon.png",
});
});
it("returns only remoteIcon when no local icon resolves", async () => {
const { resolveUpdateItemIcons } = await loadIconsModule({});
expect(
resolveUpdateItemIcons({
pkgname: "spark-clock",
source: "apm",
currentVersion: "1.0.0",
nextVersion: "2.0.0",
category: "utility",
arch: "amd64",
}),
).toEqual({
remoteIcon:
"https://erotica.spark-app.store/amd64-apm/utility/spark-clock/icon.png",
});
});
it("returns only localIcon when a remote fallback URL cannot be built", async () => {
const pkgname = "spark-reader";
const applicationsDirectory = "/usr/share/applications";
const desktopPath = `${applicationsDirectory}/reader-launcher.desktop`;
const iconPath = `/usr/share/pixmaps/${pkgname}.png`;
const { resolveUpdateItemIcons } = await loadIconsModule({
directories: {
[applicationsDirectory]: ["reader-launcher.desktop"],
},
files: {
[desktopPath]: `[Desktop Entry]\nName=Spark Reader\nIcon=${iconPath}\n`,
[iconPath]: "png",
},
packageFiles: {
[pkgname]: [desktopPath],
},
});
expect(
resolveUpdateItemIcons({
pkgname,
source: "aptss",
currentVersion: "1.0.0",
nextVersion: "2.0.0",
}),
).toEqual({
localIcon: iconPath,
});
});
it("returns an empty object when neither local nor remote icons are available", async () => {
const { resolveUpdateItemIcons } = await loadIconsModule({});
expect(
resolveUpdateItemIcons({
pkgname: "spark-empty",
source: "aptss",
currentVersion: "1.0.0",
nextVersion: "2.0.0",
}),
).toEqual({});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npm run test -- --run src/__tests__/unit/update-center/icons.test.ts`
Expected: FAIL because `resolveUpdateItemIcon()` still returns a string and `resolveUpdateItemIcons()` does not exist yet.
- [ ] **Step 3: Write minimal implementation**
Update `electron/main/backend/update-center/types.ts` so the interface defines the two source fields instead of `icon`:
```ts
export interface UpdateCenterItem {
pkgname: string;
source: UpdateSource;
currentVersion: string;
nextVersion: string;
arch?: string;
category?: string;
localIcon?: string;
remoteIcon?: string;
ignored?: boolean;
downloadUrl?: string;
fileName?: string;
size?: number;
sha512?: string;
isMigration?: boolean;
migrationSource?: UpdateSource;
migrationTarget?: UpdateSource;
aptssVersion?: string;
}
```
Replace the old single-result helper at the end of `electron/main/backend/update-center/icons.ts` with this code:
```ts
export interface UpdateItemIcons {
localIcon?: string;
remoteIcon?: string;
}
export const resolveUpdateItemIcons = (
item: UpdateCenterItem,
): UpdateItemIcons => {
const localIcon =
item.source === "aptss"
? resolveDesktopIcon(item.pkgname)
: resolveApmIcon(item.pkgname);
const remoteIcon =
buildRemoteFallbackIconUrl({
pkgname: item.pkgname,
source: item.source,
arch: item.arch,
category: item.category,
}) || undefined;
return {
...(localIcon ? { localIcon } : {}),
...(remoteIcon ? { remoteIcon } : {}),
};
};
```
Keep `resolveDesktopIcon()`, `resolveApmIcon()`, and `buildRemoteFallbackIconUrl()` unchanged.
- [ ] **Step 4: Run test to verify it passes**
Run: `npm run test -- --run src/__tests__/unit/update-center/icons.test.ts`
Expected: PASS with the updated icon helper tests green.
- [ ] **Step 5: Commit**
```bash
git add electron/main/backend/update-center/types.ts electron/main/backend/update-center/icons.ts src/__tests__/unit/update-center/icons.test.ts
git commit -m "fix(update-center): split local and remote icon sources"
```
### Task 2: Propagate Icon Sources Through Loaded Items And Service Snapshots
**Files:**
- Modify: `electron/main/backend/update-center/index.ts`
- Modify: `electron/main/backend/update-center/service.ts`
- Modify: `src/global/typedefinition.ts`
- Test: `src/__tests__/unit/update-center/load-items.test.ts`
- Test: `src/__tests__/unit/update-center/registerUpdateCenter.test.ts`
- [ ] **Step 1: Write the failing tests**
Update the expected item snapshots in `src/__tests__/unit/update-center/load-items.test.ts` from `icon` to `remoteIcon`:
```ts
expect(result.items).toContainEqual({
pkgname: "spark-weather",
source: "apm",
currentVersion: "1.5.0",
nextVersion: "3.0.0",
arch: "amd64",
category: "tools",
remoteIcon:
"https://erotica.spark-app.store/amd64-apm/tools/spark-weather/icon.png",
downloadUrl: "https://example.invalid/spark-weather_3.0.0_amd64.deb",
fileName: "spark-weather_3.0.0_amd64.deb",
size: 123456,
sha512: "deadbeef",
isMigration: true,
migrationSource: "aptss",
migrationTarget: "apm",
aptssVersion: "2.0.0",
});
```
```ts
expect(result.items).toEqual([
{
pkgname: "spark-notes",
source: "aptss",
currentVersion: "1.0.0",
nextVersion: "2.0.0",
arch: "amd64",
category: "office",
remoteIcon:
"https://erotica.spark-app.store/amd64-store/office/spark-notes/icon.png",
},
]);
```
```ts
expect(secondResult.items).toEqual([
{
pkgname: "spark-notes",
source: "aptss",
currentVersion: "1.0.0",
nextVersion: "2.0.0",
arch: "amd64",
category: "office",
remoteIcon:
"https://erotica.spark-app.store/amd64-store/office/spark-notes/icon.png",
},
]);
```
```ts
expect(result.items).toEqual([
{
pkgname: "spark-notes",
source: "aptss",
currentVersion: "1.0.0",
nextVersion: "2.0.0",
arch: "amd64",
category: "office",
remoteIcon:
"https://erotica.spark-app.store/amd64-store/office/spark-notes/icon.png",
},
]);
```
Replace the icon-preservation test in `src/__tests__/unit/update-center/registerUpdateCenter.test.ts` with:
```ts
it("service task snapshots keep localIcon and remoteIcon for queued work", async () => {
const service = createUpdateCenterService({
loadItems: async () => [
{
...createItem(),
localIcon: "/icons/weather.png",
remoteIcon: "https://example.com/weather.png",
},
],
createTaskRunner: (queue: UpdateCenterQueue) => ({
cancelActiveTask: vi.fn(),
runNextTask: async () => {
const task = queue.getNextQueuedTask();
if (!task) {
return null;
}
queue.markActiveTask(task.id, "installing");
queue.finishTask(task.id, "completed");
return task;
},
}),
});
await service.refresh();
await service.start(["aptss:spark-weather"]);
expect(service.getState().tasks).toMatchObject([
{
taskKey: "aptss:spark-weather",
localIcon: "/icons/weather.png",
remoteIcon: "https://example.com/weather.png",
status: "completed",
},
]);
});
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `npm run test -- --run src/__tests__/unit/update-center/load-items.test.ts src/__tests__/unit/update-center/registerUpdateCenter.test.ts`
Expected: FAIL because the loader and service snapshots still publish `icon` instead of `localIcon` / `remoteIcon`.
- [ ] **Step 3: Write minimal implementation**
Update the icon enrichment function in `electron/main/backend/update-center/index.ts`:
```ts
import { resolveUpdateItemIcons } from "./icons";
const enrichItemIcons = (items: UpdateCenterItem[]): UpdateCenterItem[] => {
return items.map((item) => {
const { localIcon, remoteIcon } = resolveUpdateItemIcons(item);
if (!localIcon && !remoteIcon) {
return item;
}
return {
...item,
...(localIcon ? { localIcon } : {}),
...(remoteIcon ? { remoteIcon } : {}),
};
});
};
```
Update the renderer-facing item/task types and `toState()` mapping in `electron/main/backend/update-center/service.ts`:
```ts
export interface UpdateCenterServiceItem {
taskKey: string;
packageName: string;
displayName: string;
currentVersion: string;
newVersion: string;
source: UpdateSource;
localIcon?: string;
remoteIcon?: string;
ignored?: boolean;
downloadUrl?: string;
fileName?: string;
size?: number;
sha512?: string;
isMigration?: boolean;
migrationSource?: UpdateSource;
migrationTarget?: UpdateSource;
aptssVersion?: string;
}
export interface UpdateCenterServiceTask {
taskKey: string;
packageName: string;
source: UpdateSource;
localIcon?: string;
remoteIcon?: string;
status: UpdateCenterQueueSnapshot["tasks"][number]["status"];
progress: number;
logs: UpdateCenterQueueSnapshot["tasks"][number]["logs"];
errorMessage: string;
}
const toState = (
snapshot: UpdateCenterQueueSnapshot,
): UpdateCenterServiceState => ({
items: snapshot.items.map((item) => ({
taskKey: getTaskKey(item),
packageName: item.pkgname,
displayName: item.pkgname,
currentVersion: item.currentVersion,
newVersion: item.nextVersion,
source: item.source,
localIcon: item.localIcon,
remoteIcon: item.remoteIcon,
ignored: item.ignored,
downloadUrl: item.downloadUrl,
fileName: item.fileName,
size: item.size,
sha512: item.sha512,
isMigration: item.isMigration,
migrationSource: item.migrationSource,
migrationTarget: item.migrationTarget,
aptssVersion: item.aptssVersion,
})),
tasks: snapshot.tasks.map((task) => ({
taskKey: getTaskKey(task.item),
packageName: task.pkgname,
source: task.item.source,
localIcon: task.item.localIcon,
remoteIcon: task.item.remoteIcon,
status: task.status,
progress: task.progress,
logs: task.logs.map((log) => ({ ...log })),
errorMessage: task.error ?? "",
})),
warnings: [...snapshot.warnings],
hasRunningTasks: snapshot.hasRunningTasks,
});
```
Update the update-center renderer types in `src/global/typedefinition.ts`:
```ts
export interface UpdateCenterItem {
taskKey: string;
packageName: string;
displayName: string;
currentVersion: string;
newVersion: string;
source: UpdateSource;
localIcon?: string;
remoteIcon?: string;
ignored?: boolean;
downloadUrl?: string;
fileName?: string;
size?: number;
sha512?: string;
isMigration?: boolean;
migrationSource?: UpdateSource;
migrationTarget?: UpdateSource;
aptssVersion?: string;
}
export interface UpdateCenterTaskState {
taskKey: string;
packageName: string;
source: UpdateSource;
localIcon?: string;
remoteIcon?: string;
status: UpdateCenterTaskStatus;
progress: number;
logs: Array<{ time: number; message: string }>;
errorMessage: string;
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `npm run test -- --run src/__tests__/unit/update-center/load-items.test.ts src/__tests__/unit/update-center/registerUpdateCenter.test.ts`
Expected: PASS with the loader and service tests green.
- [ ] **Step 5: Commit**
```bash
git add electron/main/backend/update-center/index.ts electron/main/backend/update-center/service.ts src/global/typedefinition.ts src/__tests__/unit/update-center/load-items.test.ts src/__tests__/unit/update-center/registerUpdateCenter.test.ts
git commit -m "refactor(update-center): propagate icon fallback fields"
```
### Task 3: Implement Renderer Fallback Order In UpdateCenterItem.vue
**Files:**
- Modify: `src/components/update-center/UpdateCenterItem.vue`
- Test: `src/__tests__/unit/update-center/UpdateCenterItem.test.ts`
- [ ] **Step 1: Write the failing test**
Replace the contents of `src/__tests__/unit/update-center/UpdateCenterItem.test.ts` with:
```ts
import { fireEvent, render, screen } from "@testing-library/vue";
import { describe, expect, it } from "vitest";
import UpdateCenterItem from "@/components/update-center/UpdateCenterItem.vue";
import type {
UpdateCenterItem as UpdateCenterItemData,
UpdateCenterTaskState,
} from "@/global/typedefinition";
const createItem = (
overrides: Partial<UpdateCenterItemData> = {},
): UpdateCenterItemData => ({
taskKey: "aptss:spark-weather",
packageName: "spark-weather",
displayName: "Spark Weather",
currentVersion: "1.0.0",
newVersion: "2.0.0",
source: "aptss",
...overrides,
});
const createTask = (
overrides: Partial<UpdateCenterTaskState> = {},
): UpdateCenterTaskState => ({
taskKey: "aptss:spark-weather",
packageName: "spark-weather",
source: "aptss",
status: "downloading",
progress: 42,
logs: [],
errorMessage: "",
...overrides,
});
describe("UpdateCenterItem", () => {
it("renders localIcon first when both icon sources exist", () => {
render(UpdateCenterItem, {
props: {
item: createItem({
localIcon: "/usr/share/pixmaps/spark-weather.png",
remoteIcon: "https://example.com/spark-weather.png",
}),
task: createTask(),
selected: false,
},
});
const icon = screen.getByRole("img", { name: "Spark Weather 图标" });
expect(icon).toHaveAttribute(
"src",
"file:///usr/share/pixmaps/spark-weather.png",
);
});
it("falls back to remoteIcon when localIcon fails", async () => {
render(UpdateCenterItem, {
props: {
item: createItem({
localIcon: "/usr/share/pixmaps/spark-weather.png",
remoteIcon: "https://example.com/spark-weather.png",
}),
task: createTask(),
selected: false,
},
});
const icon = screen.getByRole("img", { name: "Spark Weather 图标" });
await fireEvent.error(icon);
expect(icon).toHaveAttribute(
"src",
"https://example.com/spark-weather.png",
);
});
it("falls back to the placeholder after localIcon and remoteIcon both fail", async () => {
render(UpdateCenterItem, {
props: {
item: createItem({
localIcon: "/usr/share/pixmaps/spark-weather.png",
remoteIcon: "https://example.com/spark-weather.png",
}),
task: createTask(),
selected: false,
},
});
const icon = screen.getByRole("img", { name: "Spark Weather 图标" });
await fireEvent.error(icon);
await fireEvent.error(icon);
expect(icon.getAttribute("src")).toContain("data:image/svg+xml");
expect(icon.getAttribute("src")).not.toContain(
"https://example.com/spark-weather.png",
);
});
it("restarts from localIcon when a new item is rendered", async () => {
const { rerender } = render(UpdateCenterItem, {
props: {
item: createItem({
localIcon: "/usr/share/pixmaps/spark-weather.png",
remoteIcon: "https://example.com/spark-weather.png",
}),
task: createTask(),
selected: false,
},
});
const firstIcon = screen.getByRole("img", { name: "Spark Weather 图标" });
await fireEvent.error(firstIcon);
expect(firstIcon).toHaveAttribute(
"src",
"https://example.com/spark-weather.png",
);
await rerender({
item: createItem({
taskKey: "aptss:spark-clock",
packageName: "spark-clock",
displayName: "Spark Clock",
localIcon: "/usr/share/pixmaps/spark-clock.png",
remoteIcon: "https://example.com/spark-clock.png",
}),
task: createTask({
taskKey: "aptss:spark-clock",
packageName: "spark-clock",
}),
selected: false,
});
const nextIcon = screen.getByRole("img", { name: "Spark Clock 图标" });
expect(nextIcon).toHaveAttribute(
"src",
"file:///usr/share/pixmaps/spark-clock.png",
);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npm run test -- --run src/__tests__/unit/update-center/UpdateCenterItem.test.ts`
Expected: FAIL because the component still reads `item.icon` and goes straight from a single failed image to the placeholder.
- [ ] **Step 3: Write minimal implementation**
Replace the `<script setup>` block in `src/components/update-center/UpdateCenterItem.vue` with:
```ts
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import type {
UpdateCenterItem,
UpdateCenterTaskState,
} from "@/global/typedefinition";
const props = defineProps<{
item: UpdateCenterItem;
task?: UpdateCenterTaskState;
selected: boolean;
}>();
const PLACEHOLDER_ICON =
'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"%3E%3Crect width="48" height="48" rx="12" fill="%23e2e8f0"/%3E%3Cpath d="M17 31h14v2H17zm3-12h8a2 2 0 0 1 2 2v8H18v-8a2 2 0 0 1 2-2" fill="%2394a3b8"/%3E%3C/svg%3E';
const currentIconIndex = ref(0);
const allCandidatesFailed = ref(false);
defineEmits<{
(e: "toggle-selection"): void;
}>();
const normalizeIconSrc = (icon: string): string => {
if (/^[a-z]+:\/\//i.test(icon)) {
return icon;
}
return icon.startsWith("/") ? `file://${icon}` : icon;
};
const iconCandidates = computed(() => {
return [props.item.localIcon, props.item.remoteIcon]
.filter((icon): icon is string => Boolean(icon && icon.trim().length > 0))
.map((icon) => normalizeIconSrc(icon));
});
const resetIconFallback = () => {
currentIconIndex.value = 0;
allCandidatesFailed.value = false;
};
const handleIconError = () => {
if (currentIconIndex.value < iconCandidates.value.length - 1) {
currentIconIndex.value += 1;
return;
}
allCandidatesFailed.value = true;
};
watch(
() => props.item,
() => {
resetIconFallback();
},
);
const iconSrc = computed(() => {
if (allCandidatesFailed.value || iconCandidates.value.length === 0) {
return PLACEHOLDER_ICON;
}
return iconCandidates.value[currentIconIndex.value] ?? PLACEHOLDER_ICON;
});
const sourceLabel = computed(() => {
return props.item.source === "apm" ? "APM" : "传统deb";
});
const statusLabel = computed(() => {
switch (props.task?.status) {
case "downloading":
return "下载中";
case "installing":
return "安装中";
case "completed":
return "已完成";
case "failed":
return "失败";
case "cancelled":
return "已取消";
default:
return "待处理";
}
});
const showProgress = computed(() => {
return (
props.task?.status === "downloading" || props.task?.status === "installing"
);
});
const progressText = computed(() => `${props.task?.progress ?? 0}%`);
const progressStyle = computed(() => ({ width: progressText.value }));
</script>
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npm run test -- --run src/__tests__/unit/update-center/UpdateCenterItem.test.ts`
Expected: PASS with all fallback-order component tests green.
- [ ] **Step 5: Commit**
```bash
git add src/components/update-center/UpdateCenterItem.vue src/__tests__/unit/update-center/UpdateCenterItem.test.ts
git commit -m "fix(update-center): cascade icon fallback in renderer"
```
### Task 4: Verify The Full Change Set
**Files:**
- Verify only: `electron/main/backend/update-center/types.ts`
- Verify only: `electron/main/backend/update-center/icons.ts`
- Verify only: `electron/main/backend/update-center/index.ts`
- Verify only: `electron/main/backend/update-center/service.ts`
- Verify only: `src/global/typedefinition.ts`
- Verify only: `src/components/update-center/UpdateCenterItem.vue`
- Verify only: `src/__tests__/unit/update-center/icons.test.ts`
- Verify only: `src/__tests__/unit/update-center/load-items.test.ts`
- Verify only: `src/__tests__/unit/update-center/registerUpdateCenter.test.ts`
- Verify only: `src/__tests__/unit/update-center/UpdateCenterItem.test.ts`
- [ ] **Step 1: Run the focused update-center test suite**
Run: `npm run test -- --run src/__tests__/unit/update-center/icons.test.ts src/__tests__/unit/update-center/load-items.test.ts src/__tests__/unit/update-center/registerUpdateCenter.test.ts src/__tests__/unit/update-center/UpdateCenterItem.test.ts`
Expected: PASS with all four update-center suites green.
- [ ] **Step 2: Run the formatter**
Run: `npm run format`
Expected: command exits 0 after formatting the touched files.
- [ ] **Step 3: Run lint**
Run: `npm run lint`
Expected: PASS with no ESLint or Prettier violations.
- [ ] **Step 4: Run the production build**
Run: `npm run build`
Expected: PASS with Vite/Electron build output generated successfully and no TypeScript errors.
@@ -0,0 +1,229 @@
# 更新中心图标逐级回退设计
## 背景
当前更新中心的图标解析分成两段:
1. 主进程 `electron/main/backend/update-center/icons.ts` 会优先解析本地图标,解析不到时再直接返回线上图标 URL。
2. 渲染层 `src/components/update-center/UpdateCenterItem.vue` 只接收单个 `icon` 字段,图片加载失败后直接回退到默认占位图。
这个结构已经满足“本地优先”的静态选择,但不能满足新的行为要求:
1. 当本地图标成功加载时,不再请求线上图标和默认图标。
2. 当本地图标路径虽然存在、但实际加载失败时,继续尝试线上图标。
3. 当线上图标也失败时,最后才回退到默认占位图。
问题根因不是路径优先级判断错误,而是当前前后端只传递了一个最终 `icon` 值,导致前端无法在运行时根据真实加载结果继续尝试下一层来源。
## 目标
1. 更新中心图标加载顺序固定为:`localIcon -> remoteIcon -> placeholder`
2. 本地图标加载成功时,不再加载线上图标和默认图标。
3. 本地图标加载失败时,自动切换到线上图标。
4. 线上图标也失败时,才显示默认占位图。
5. 保持当前更新中心列表布局、图标尺寸和已有解析路径规则不变。
## 非目标
1. 不改动主商店、已安装列表或其他页面的图标逻辑。
2. 不增加新的网络探测请求,也不预检远程图标是否可访问。
3. 不重构现有本地图标解析算法,只调整数据结构和回退链路。
4. 不引入通用的图标来源数组或复杂图标对象。
## 方案选择
本次考虑过三种方案:
1. 后端透传 `localIcon``remoteIcon` 两个字段,前端顺序尝试。
2. 后端透传 `iconCandidates: string[]`,前端按数组顺序尝试。
3. 继续只传一个 `icon`,前端根据 `pkgname/category/arch` 自己重新拼线上图标地址。
最终选择方案 1。
原因:
1. 它刚好对应本次明确的三级回退需求,最小且直接。
2. 后端继续掌握图标来源规则,避免前端复制商店 URL 规则。
3. 相比数组方案,双字段更易读、更容易在 IPC 类型中维护。
4. 前端只负责“加载失败后切换到下一来源”,职责边界清晰。
## 设计概览
更新中心改为“主进程解析来源,渲染层控制加载顺序”的结构:
1. 主进程为每个更新项分别计算 `localIcon``remoteIcon`
2. 服务层和前端类型透传这两个字段。
3. `UpdateCenterItem.vue``localIcon -> remoteIcon -> placeholder` 的顺序逐级尝试。
4. 候选图标一旦成功加载,组件不再切换到后续来源。
## 数据结构变更
### 主进程类型
修改:`electron/main/backend/update-center/types.ts`
将:
```ts
icon?: string;
```
改为:
```ts
localIcon?: string;
remoteIcon?: string;
```
### Service Snapshot
修改:`electron/main/backend/update-center/service.ts`
更新 renderer-facing item/task 类型,并在 `toState()` 中透传:
```ts
localIcon?: string;
remoteIcon?: string;
```
### 渲染层类型
修改:`src/global/typedefinition.ts`
更新 `UpdateCenterItem``UpdateCenterTaskState`
```ts
localIcon?: string;
remoteIcon?: string;
```
## 模块边界
### `electron/main/backend/update-center/icons.ts`
保留现有职责,但返回内容从“单个最终图标”调整为“两种候选来源”:
1. `resolveDesktopIcon(pkgname)`:解析传统 deb / aptss 更新项的本地图标。
2. `resolveApmIcon(pkgname)`:解析 APM 更新项的本地图标。
3. `buildRemoteFallbackIconUrl(item)`:拼接远程商店图标地址。
4. `resolveUpdateItemIcons(item)`:组合出 `{ localIcon?, remoteIcon? }`
这里不再提前做“本地失败就直接放弃线上”的最终决策,而是把两个候选来源都准备好交给前端。
### `electron/main/backend/update-center/index.ts`
在更新项 enrichment 阶段,将:
1. 现有的单 `icon` 注入逻辑。
调整为:
1. 读取 `resolveUpdateItemIcons(item)` 的结果。
2. 仅在字段存在时把 `localIcon` / `remoteIcon` 写回更新项。
### `src/components/update-center/UpdateCenterItem.vue`
组件不再把单个 `item.icon` 当成最终地址,而是:
1.`item.localIcon``item.remoteIcon` 派生候选列表。
2. 使用当前索引决定 `img.src`
3. 失败时切到下一候选项。
4. 候选项耗尽后切到占位图。
## 详细数据流
### 主进程加载更新项
1. 更新中心主进程加载更新项。
2. 现有逻辑继续补齐 `category``arch` 等字段。
3. 图标模块为每个项分别解析:
- `localIcon`:本地图标路径。
- `remoteIcon`:线上图标 URL。
4. enrichment 后的更新项通过 service snapshot 发送到渲染层。
### 渲染层展示更新项
1. 组件收到 `item.localIcon` / `item.remoteIcon`
2. 组件构造一个有序候选列表:
- 本地路径转换为 `file://` URL。
- 远程 URL 原样使用。
3. 初始渲染第 1 个候选图标。
4. 如果 `img` 加载成功,流程结束,不再切换到下一项。
5. 如果 `img` 触发 `error`,索引递增,继续尝试下一候选图标。
6. 如果所有候选都失败,切换到占位图。
## 前端行为细节
### 候选列表生成规则
候选列表只包含存在且非空的来源:
1. `localIcon` 存在时放在第 1 位。
2. `remoteIcon` 存在时放在第 2 位。
3. 占位图不放入候选列表,而是在候选耗尽后单独回退。
这样可以避免:
1. 本地图标成功时还额外发起线上请求。
2. 图标字段为空时出现无意义的重试。
### 状态重置规则
`props.item` 变为新的更新项对象时:
1. 重置当前候选索引到第 1 项。
2. 清空“候选已耗尽”的状态。
3. 重新开始本地优先的尝试流程。
这样可确保列表复用或重新渲染时,新条目不会继承上一条目的失败状态。
### 占位图规则
保留当前组件内默认占位 SVG,不改样式和尺寸。
只有在以下情况下才使用占位图:
1. `localIcon``remoteIcon` 都不存在。
2. `localIcon` 加载失败且 `remoteIcon` 不存在。
3. `localIcon``remoteIcon` 都加载失败。
## 错误处理
1. 本地图标路径不存在或不可读:允许浏览器触发加载失败,再由前端切到线上图标。
2. 远程图标返回 404、超时或其他加载错误:前端切到占位图,不向用户弹额外错误。
3. 后端无法推断 `category``arch`:允许 `remoteIcon` 为空,前端只尝试本地图标和占位图。
4. 任一图标来源失败都不能影响更新列表正文、状态标签和进度条显示。
## 测试方案
### 后端测试
扩展 `src/__tests__/unit/update-center/icons.test.ts`
1. 本地图标可解析时,`resolveUpdateItemIcons()` 返回 `localIcon`,并在条件满足时同时包含 `remoteIcon`
2. 本地图标缺失时,仍可返回 `remoteIcon`
3. 缺少 `category``arch` 时,不返回 `remoteIcon`
4. 两者都不可得时,返回空对象。
### 组件测试
扩展 `src/__tests__/unit/update-center/UpdateCenterItem.test.ts`
1.`localIcon` 时先渲染本地 `file://` 地址。
2. 本地图标未失败前,不切换到 `remoteIcon`
3. 本地图标触发 `error` 后切到 `remoteIcon`
4. 本地和线上都触发 `error` 后切到默认占位图。
5. 切换到新的 `item` 后,回退状态会重置。
## 风险与约束
1. 如果某些包的本地图标路径在后端看来存在,但渲染进程实际不可访问,仍会触发一次失败请求;这是预期行为,因为它正是继续尝试线上图标的触发条件。
2. 远程图标 URL 继续依赖当前商店路径规则,若个别包没有线上图标,最终仍会使用占位图。
3. 本次只调整更新中心图标链路,不同步抽象其他页面,避免扩大改动范围。
## 决策总结
1.`localIcon``remoteIcon` 替代单个 `icon` 字段。
2. 主进程负责解析来源,渲染层负责按顺序加载和失败回退。
3. 固定回退顺序为:本地图标 -> 线上图标 -> 默认占位图。
4. 本地图标成功时,不再加载线上图标和默认图标。
@@ -0,0 +1,196 @@
# 更新中心 Spark 更新命令设计
## 背景
当前 Electron 更新中心对 `aptss` 来源的更新项仍保留一条旧路径:当任务没有本地下载文件时,直接执行 `shell-caller.sh aptss install -y <pkg> --only-upgrade`。这条路径会在宿主系统里直接升级软件包,但不会复用 Qt 更新器已经采用的“先下载 deb,再通过 `ssinstall` 安装”的流程。
仓库里已经存在一个更贴近更新器预期的行为参考:旧 Qt 更新器在安装 `aptss` 来源更新时,会对下载好的 deb 调用 `ssinstall`,并带上“不创建桌面快捷方式”和“安装后删除下载文件”等参数。
本次需求是:仅对 Electron 更新中心生效,把 Spark 软件包更新改为走 `shell-caller` 顶层 `ssinstall` 路径,同时避免更新时创建新的桌面项。
## 目标
1. Electron 更新中心处理 `aptss` 更新时,统一改为“下载 deb -> `shell-caller.sh ssinstall` 安装”。
2. 更新时传入 `ssinstall` 的“不创建桌面项”参数,避免更新流程额外生成桌面快捷方式。
3. 变更只作用于 Electron 更新中心,不影响普通安装流、APM 更新流和 `extras/shell-caller.sh` 的白名单行为。
4. 继续沿用现有提权方式:若存在 `pkexec`,仍通过 `pkexec /opt/spark-store/extras/shell-caller.sh ...` 执行。
## 非目标
1. 不修改 `electron/main/backend/install-manager.ts` 的普通安装逻辑。
2. 不修改 `apm` 来源更新的下载与安装方式。
3. 不扩展 `extras/shell-caller.sh` 以支持新的 `aptss ssinstall` 子命令形式。
4. 不修改旧 Qt 更新器行为;它只作为现有参考实现。
## 已确认的命令约束
### shell-caller 约束
当前仓库内的 `extras/shell-caller.sh` 只支持 3 个顶层命令类型:
1. `apm`
2. `aptss`
3. `ssinstall`
其中 `aptss` 仅允许 `install``remove` 两个子命令,不支持 `aptss ssinstall ...`。因此,本次实现不会尝试新增 `shell-caller aptss ssinstall` 这种调用形式,而是直接使用已存在的顶层 `ssinstall` 入口。
### ssinstall 参数名
本机 `ssinstall --help` 显示的真实参数名是:
```bash
--no-create-desktop-entry
```
因此,需求里口头表达的 `--no-create-desktop` 会在实现中落到 `--no-create-desktop-entry`,避免引入不存在的参数名。
## 现状问题
当前更新中心后端只有 APM 更新项会在刷新阶段补齐 `downloadUrl``fileName``size``sha512` 等下载元数据。`aptss` 更新项只来自 `apt list --upgradable` 的文本解析结果,因此:
1. `aptss` 更新项通常没有可下载 deb 的元数据。
2. 没有 deb 文件时,安装逻辑会退回旧的 `aptss install --only-upgrade` 命令。
3. 这使得 Electron 更新中心无法像 Qt 更新器那样稳定走 `ssinstall` 路径。
## 方案概览
采用“刷新阶段补齐 `aptss` 下载元数据,执行阶段统一走 `ssinstall`”的方案。
整体流程如下:
1. 刷新更新列表时,继续查询 `aptss` 的可升级包。
2. 对每个 `aptss` 更新项额外查询 `apt download --print-uris` 元数据。
3. 只有拿到 `downloadUrl``fileName``aptss` 更新项才进入最终更新列表。
4. 执行更新任务时,先下载对应 deb。
5. 下载完成后调用 `shell-caller.sh ssinstall <deb> --no-create-desktop-entry --delete-after-install`
6. 若存在提权命令,则实际执行 `pkexec /opt/spark-store/extras/shell-caller.sh ssinstall ...`
这样可以让 Electron 更新中心的 `aptss` 更新行为与 Qt 更新器保持一致,同时严格限定在更新中心内部,不影响商店其他安装入口。
## 模块变更
### 1. `electron/main/backend/update-center/index.ts`
新增 `aptss` 下载元数据补全逻辑,方式与现有 APM 元数据补全保持一致。
建议变更:
1. 新增一个 `aptss``print-uris` 命令构造函数,复用当前 `apt-fast` 配置与源列表参数。
2. 复用现有 `parsePrintUrisOutput()` 解析函数,不新增第二套解析器。
3.`aptss` 更新项新增与 APM 相同的元数据补全过程。
4. 元数据查询失败的 `aptss` 项从最终可更新列表中剔除,并写入 warning。
这样做的原因是:更新中心一旦展示某个更新项,就应该能够实际完成下载和安装,而不是在任务执行阶段才发现缺少 deb 元数据。
### 2. `electron/main/backend/update-center/install.ts`
`aptss` 更新项的安装路径改为严格依赖已下载的 `filePath`
行为调整:
1. `item.source === "aptss"` 且有 `filePath` 时,执行 `shell-caller.sh ssinstall`
2. 传参为:
```bash
ssinstall <deb-path> --no-create-desktop-entry --delete-after-install
```
3. 若存在 `superUserCmd`,则通过 `buildPrivilegedCommand()` 包装成:
```bash
/usr/bin/pkexec /opt/spark-store/extras/shell-caller.sh ssinstall <deb-path> --no-create-desktop-entry --delete-after-install
```
4. 删除 `aptss` 无文件时回退到 `buildLegacySparkUpgradeCommand()` 的行为。
这意味着 `aptss` 更新不再允许悄悄退回旧式 `aptss install --only-upgrade` 流程。
### 3. 其他模块
以下模块不应发生行为变化:
1. `electron/main/backend/install-manager.ts`
2. `extras/shell-caller.sh`
3. `spark-update-tool/` 中的 Qt 更新器逻辑
4. `apm` 来源更新的下载与安装分支
## 数据流
### 刷新阶段
1. 读取 `aptss``apm` 的可升级列表。
2. 读取已安装来源状态。
3.`aptss` 更新项加载 deb 元数据。
4.`apm` 更新项加载 deb 元数据。
5. 合并来源、迁移标记、图标和其他展示字段。
6. 返回只包含“可实际下载并安装”的更新项列表。
### 执行阶段
1. 任务进入 `downloading`
2. 使用已有 aria2 下载器下载 deb。
3. 任务进入 `installing`
4. `aptss` 项执行 `shell-caller.sh ssinstall`
5. `apm` 项继续执行当前 `shell-caller.sh apm ssinstall` 流程。
6. 成功后标记完成,失败则保留日志与错误信息。
## 错误处理
### 刷新失败
如果某个 `aptss` 包的元数据查询失败:
1. 不让该项进入可更新列表。
2.`warnings` 中记录具体失败信息,例如 `aptss metadata query for <pkg> failed ...`
3. 不影响其他更新项展示。
### 安装失败
如果 `shell-caller.sh ssinstall ...` 返回非 0
1. 保持当前任务失败处理逻辑不变。
2. 将 stdout/stderr 继续写入任务日志。
3. 由任务队列把该更新项标记为 `failed`
### 取消任务
取消逻辑保持不变。只要下载或安装子进程被中止,任务仍按当前机制进入 `cancelled``failed` 分支,不额外引入新的取消状态。
## 测试方案
### 单元测试
先写失败测试,再改实现。至少覆盖以下场景:
1. `load-items.test.ts`
- `aptss` 更新项会额外查询 `print-uris` 元数据。
- 元数据成功时,结果包含 `downloadUrl``fileName`
- 元数据失败时,该项被过滤并写入 warning。
2. `task-runner.test.ts`
- `aptss` 文件安装走 `shell-caller.sh ssinstall <deb> --no-create-desktop-entry --delete-after-install`
- 不再断言旧的 `buildLegacySparkUpgradeCommand()` 输出。
- `apm` 文件安装仍走 `shell-caller.sh apm ssinstall <deb>`,避免回归。
3. 如有必要,为安装构造函数补充更细粒度测试,确保带 `superUserCmd` 时参数顺序正确。
### 验证命令
实现完成后至少执行:
1. `npm run test -- --run src/__tests__/unit/update-center/load-items.test.ts src/__tests__/unit/update-center/task-runner.test.ts`
2. `npm run lint`
3. `npm run build`
## 风险与约束
1. `aptss` 元数据查询会为每个更新项新增一次命令调用,刷新成本会增加,但这是换取 updater-only `ssinstall` 行为所必需的最小代价。
2. 若某些仓库源对 `apt download --print-uris` 返回格式异常,相关更新项会被过滤并显示 warning;这比静默退回旧命令更符合本次需求。
3. `shell-caller.sh ssinstall` 会自动补上 `--native`,因此更新中心无需重复传入该参数。
## 决策总结
1. Electron 更新中心的 `aptss` 更新改为“下载 deb 后通过顶层 `shell-caller.sh ssinstall` 安装”。
2. 实际使用的桌面项参数名为 `--no-create-desktop-entry`
3. 删除 `aptss` 更新回退到 `aptss install --only-upgrade` 的旧行为。
4. 该变更只作用于 `electron/main/backend/update-center/`,不修改其他安装入口。
+1 -1
View File
@@ -15,7 +15,7 @@ extraResources:
to: "icons" to: "icons"
linux: linux:
icon: "icons/amber-pm-logo.icns" icon: "icons/spark-store.png"
category: "System" category: "System"
executableName: "spark-store" executableName: "spark-store"
desktop: desktop:
+18 -19
View File
@@ -148,8 +148,7 @@ ipcMain.on("queue-install", async (event, download_json) => {
typeof download_json === "string" typeof download_json === "string"
? JSON.parse(download_json) ? JSON.parse(download_json)
: download_json; : download_json;
const { id, pkgname, metalinkUrl, filename, upgradeOnly, origin } = const { id, pkgname, metalinkUrl, filename, origin } = download || {};
download || {};
if (!id || !pkgname) { if (!id || !pkgname) {
logger.warn("passed arguments missing id or pkgname"); logger.warn("passed arguments missing id or pkgname");
@@ -249,24 +248,24 @@ ipcMain.on("queue-install", async (event, download_json) => {
} }
if (origin === "spark") { if (origin === "spark") {
// Spark Store logic execCommand = superUserCmd || SHELL_CALLER_PATH;
if (upgradeOnly) { if (superUserCmd) execParams.push(SHELL_CALLER_PATH);
execCommand = superUserCmd || SHELL_CALLER_PATH;
if (superUserCmd) execParams.push(SHELL_CALLER_PATH);
execParams.push("aptss", "install", "-y", pkgname, "--only-upgrade");
} else {
execCommand = superUserCmd || SHELL_CALLER_PATH;
if (superUserCmd) execParams.push(SHELL_CALLER_PATH);
if (metalinkUrl && filename) { if (metalinkUrl && filename) {
execParams.push( execParams.push(
"ssinstall", "ssinstall",
`${downloadDir}/${filename}`, `${downloadDir}/${filename}`,
"--delete-after-install", "--delete-after-install",
); "--no-create-desktop-entry",
} else { "--native",
execParams.push("aptss", "install", "-y", pkgname); );
} } else {
execParams.push(
"ssinstall",
pkgname,
"--no-create-desktop-entry",
"--native",
);
} }
} else { } else {
// APM Store logic // APM Store logic
+368
View File
@@ -0,0 +1,368 @@
/**
* 共享的安装/下载逻辑
* 被 install-manager.ts 和 update-center 共同使用
*/
import { spawn, ChildProcess } from "node:child_process";
import { createWriteStream } from "node:fs";
import * as fs from "node:fs";
import * as path from "node:path";
import axios from "axios";
import pino from "pino";
const logger = pino({ name: "shared-installer" });
export const SHELL_CALLER_PATH = "/opt/spark-store/extras/shell-caller.sh";
export interface DownloadOptions {
pkgname: string;
metalinkUrl: string;
filename: string;
downloadDir: string;
onLog?: (msg: string) => void;
onProgress?: (progress: number) => void;
onStatus?: (status: string) => void;
signal?: AbortSignal;
}
export interface DownloadResult {
filePath: string;
downloadDir: string;
}
/**
* 下载 metalink 文件并使用 aria2c 下载 deb 包
* 与 install-manager.ts 中的下载逻辑保持一致
*/
export const downloadPackage = async ({
pkgname,
metalinkUrl,
filename,
downloadDir,
onLog,
onProgress,
onStatus,
signal,
}: DownloadOptions): Promise<DownloadResult> => {
// 1. 创建下载目录
try {
if (!fs.existsSync(downloadDir)) {
fs.mkdirSync(downloadDir, { recursive: true });
}
} catch (err) {
logger.error(`无法创建目录 ${downloadDir}: ${err}`);
throw err;
}
const metalinkPath = path.join(downloadDir, `${filename}.metalink`);
onLog?.(`正在获取 Metalink 文件: ${metalinkUrl}`);
// 2. 下载 metalink 文件
const response = await axios.get(metalinkUrl, {
baseURL: "https://erotica.spark-app.store",
responseType: "stream",
});
const writer = createWriteStream(metalinkPath);
response.data.pipe(writer);
await new Promise<void>((resolve, reject) => {
writer.on("finish", resolve);
writer.on("error", reject);
});
onLog?.("Metalink 文件下载完成");
// 3. 清理下载目录中的旧文件(保留 .metalink 文件)
const existingFiles = fs.readdirSync(downloadDir);
for (const file of existingFiles) {
if (file.endsWith(".metalink")) continue;
const filePath = path.join(downloadDir, file);
try {
fs.unlinkSync(filePath);
onLog?.(`已清理旧文件: ${file}`);
} catch (err) {
logger.warn(`清理文件失败 ${filePath}: ${err}`);
}
}
// 4. 使用 aria2c 下载 deb 文件
const aria2Args = [
`--dir=${downloadDir}`,
"--allow-overwrite=true",
"--summary-interval=1",
"--connect-timeout=10",
"--timeout=15",
"--max-tries=3",
"--retry-wait=5",
"--max-concurrent-downloads=4",
"--min-split-size=1M",
"--lowest-speed-limit=1K",
"--auto-file-renaming=false",
"-M",
metalinkPath,
];
onStatus?.("downloading");
// 下载重试逻辑:每次超时时间递增,最多3次
const timeoutList = [3000, 5000, 15000];
let retryCount = 0;
let downloadSuccess = false;
while (retryCount < timeoutList.length && !downloadSuccess) {
const currentTimeout = timeoutList[retryCount];
if (retryCount > 0) {
onLog?.(`${retryCount} 次重试下载...`);
onProgress?.(0);
// 重试前清理旧文件
const retryFiles = fs.readdirSync(downloadDir);
for (const file of retryFiles) {
if (file.endsWith(".metalink")) continue;
const filePath = path.join(downloadDir, file);
try {
fs.unlinkSync(filePath);
} catch (cleanErr) {
logger.warn(`重试清理文件失败 ${filePath}: ${cleanErr}`);
}
}
}
try {
await new Promise<void>((resolve, reject) => {
onLog?.(`启动下载: aria2c ${aria2Args.join(" ")}`);
const child = spawn("aria2c", aria2Args);
let lastProgressTime = Date.now();
let lastProgress = 0;
const progressCheckInterval = 1000;
// 设置超时检测定时器
const timeoutChecker = setInterval(() => {
const now = Date.now();
// 只在进度为0时检查超时
if (lastProgress === 0 && now - lastProgressTime > currentTimeout) {
clearInterval(timeoutChecker);
child.kill();
reject(new Error(`下载卡在0%超过 ${currentTimeout / 1000}`));
}
}, progressCheckInterval);
child.stdout.on("data", (data) => {
const str = data.toString();
// Match ( 12%) or (12%)
const match = str.match(/[0-9]+(\.[0-9]+)?%/g);
if (match) {
const p = parseFloat(match.at(-1)) / 100;
if (p > lastProgress) {
lastProgress = p;
lastProgressTime = Date.now();
}
onProgress?.(p);
}
});
child.stderr.on("data", (d) => onLog?.(`aria2c: ${d}`));
// 处理取消信号
const abortHandler = () => {
clearInterval(timeoutChecker);
child.kill();
reject(new Error("下载已取消"));
};
signal?.addEventListener("abort", abortHandler, { once: true });
child.on("close", (code) => {
clearInterval(timeoutChecker);
signal?.removeEventListener("abort", abortHandler);
if (code === 0) {
onProgress?.(1);
resolve();
} else {
reject(new Error(`Aria2c exited with code ${code}`));
}
});
child.on("error", (err) => {
clearInterval(timeoutChecker);
signal?.removeEventListener("abort", abortHandler);
reject(err);
});
});
// 检查是否已取消
if (signal?.aborted) {
throw new Error("下载已取消");
}
downloadSuccess = true;
} catch (err) {
retryCount++;
if (retryCount >= timeoutList.length) {
throw new Error(`下载失败,已重试 ${timeoutList.length} 次: ${err}`);
}
onLog?.(`下载失败,准备重试 (${retryCount}/${timeoutList.length})`);
// 等待2秒后重试
await new Promise((r) => setTimeout(r, 2000));
}
}
const filePath = path.join(downloadDir, filename);
return { filePath, downloadDir };
};
export interface InstallOptions {
pkgname: string;
filePath: string;
origin: "spark" | "apm";
superUserCmd?: string;
onLog?: (msg: string) => void;
signal?: AbortSignal;
}
/**
* 安装已下载的包
* 与 install-manager.ts 中的安装逻辑保持一致
*/
export const installPackage = async ({
pkgname,
filePath,
origin,
superUserCmd,
onLog,
signal,
}: InstallOptions): Promise<void> => {
// 构建安装命令
let execCommand = "";
const execParams: string[] = [];
if (origin === "spark") {
execCommand = superUserCmd || SHELL_CALLER_PATH;
if (superUserCmd) execParams.push(SHELL_CALLER_PATH);
execParams.push(
"ssinstall",
filePath,
"--delete-after-install",
"--no-create-desktop-entry",
"--native"
);
} else {
// APM
execCommand = superUserCmd || SHELL_CALLER_PATH;
if (superUserCmd) {
execParams.push(SHELL_CALLER_PATH);
}
execParams.push("apm", "ssinstall", filePath);
}
const cmdString = `${execCommand} ${execParams.join(" ")}`;
onLog?.(`执行安装: ${cmdString}`);
logger.info(`启动安装: ${cmdString}`);
return new Promise<void>((resolve, reject) => {
const child = spawn(execCommand, execParams, {
shell: false,
env: process.env,
});
let stdout = "";
let stderr = "";
let logBuffer = "";
let logBufferTimer: NodeJS.Timeout | null = null;
const LOG_FLUSH_MS = 100;
const flushLogBuffer = () => {
if (logBuffer.length > 0) {
onLog?.(logBuffer);
logBuffer = "";
}
logBufferTimer = null;
};
const bufferedSendLog = (message: string) => {
logBuffer += message;
if (!logBufferTimer) {
logBufferTimer = setTimeout(flushLogBuffer, LOG_FLUSH_MS);
}
};
// 处理取消信号
const abortHandler = () => {
child.kill();
reject(new Error("安装已取消"));
};
signal?.addEventListener("abort", abortHandler, { once: true });
child.stdout?.on("data", (data) => {
stdout += data.toString();
bufferedSendLog(data.toString());
});
child.stderr?.on("data", (data) => {
stderr += data.toString();
bufferedSendLog(data.toString());
});
child.on("error", (err) => {
signal?.removeEventListener("abort", abortHandler);
if (logBufferTimer) clearTimeout(logBufferTimer);
flushLogBuffer();
reject(err);
});
child.on("close", (code) => {
signal?.removeEventListener("abort", abortHandler);
if (logBufferTimer) clearTimeout(logBufferTimer);
flushLogBuffer();
if (code === 0) {
resolve();
} else {
reject(new Error(`安装失败,退出码: ${code}`));
}
});
});
};
/**
* 检查是否有 apm 命令
*/
export const checkApmAvailable = async (): Promise<boolean> => {
return new Promise((resolve) => {
const child = spawn("which", ["apm"]);
let stdout = "";
child.stdout?.on("data", (data) => {
stdout += data.toString();
});
child.on("close", (code) => {
resolve(code === 0 && stdout.trim().length > 0);
});
child.on("error", () => {
resolve(false);
});
});
};
/**
* 检查提权命令
*/
export const checkSuperUserCommand = async (): Promise<string> => {
return new Promise((resolve) => {
const child = spawn("which", ["/usr/bin/pkexec"]);
let stdout = "";
child.stdout?.on("data", (data) => {
stdout += data.toString();
});
child.on("close", (code) => {
if (code === 0) {
resolve(stdout.trim());
} else {
resolve("");
}
});
child.on("error", () => {
resolve("");
});
});
};
+13 -58
View File
@@ -1,7 +1,5 @@
import { mkdir } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { spawn } from "node:child_process"; import { downloadPackage, type DownloadResult } from "../shared-installer";
import type { UpdateCenterItem } from "./types"; import type { UpdateCenterItem } from "./types";
export interface Aria2DownloadResult { export interface Aria2DownloadResult {
@@ -16,8 +14,6 @@ export interface RunAria2DownloadOptions {
signal?: AbortSignal; signal?: AbortSignal;
} }
const PROGRESS_PATTERN = /(\d{1,3}(?:\.\d+)?)%/;
export const runAria2Download = async ({ export const runAria2Download = async ({
item, item,
downloadDir, downloadDir,
@@ -29,59 +25,18 @@ export const runAria2Download = async ({
throw new Error(`Missing download metadata for ${item.pkgname}`); throw new Error(`Missing download metadata for ${item.pkgname}`);
} }
await mkdir(downloadDir, { recursive: true }); // 使用与商店安装相同的下载逻辑
const metalinkUrl = `${item.downloadUrl}.metalink`;
const filePath = join(downloadDir, item.fileName);
const result = await downloadPackage({
await new Promise<void>((resolve, reject) => { pkgname: item.pkgname,
const child = spawn("aria2c", [ metalinkUrl,
"--dir", filename: item.fileName,
downloadDir, downloadDir,
"--out", onLog,
item.fileName, onProgress,
item.downloadUrl, signal,
]);
const abortDownload = () => {
child.kill();
reject(new Error(`Update task cancelled: ${item.pkgname}`));
};
if (signal?.aborted) {
abortDownload();
return;
}
signal?.addEventListener("abort", abortDownload, { once: true });
const handleOutput = (chunk: Buffer) => {
const message = chunk.toString().trim();
if (!message) {
return;
}
onLog?.(message);
const progressMatch = message.match(PROGRESS_PATTERN);
if (progressMatch) {
onProgress?.(Number(progressMatch[1]));
}
};
child.stdout?.on("data", handleOutput);
child.stderr?.on("data", handleOutput);
child.on("error", reject);
child.on("close", (code) => {
signal?.removeEventListener("abort", abortDownload);
if (code === 0) {
resolve();
return;
}
reject(new Error(`aria2c exited with code ${code ?? -1}`));
});
}); });
onProgress?.(100); return { filePath: result.filePath };
return { filePath };
}; };
+114 -22
View File
@@ -14,6 +14,7 @@ import {
createUpdateCenterService, createUpdateCenterService,
type UpdateCenterIgnorePayload, type UpdateCenterIgnorePayload,
type UpdateCenterService, type UpdateCenterService,
type UpdateCenterStartTask,
} from "./service"; } from "./service";
import type { UpdateCenterItem } from "./types"; import type { UpdateCenterItem } from "./types";
@@ -33,14 +34,20 @@ export interface UpdateCenterLoadItemsResult {
warnings: string[]; warnings: string[];
} }
type StoreCategoryMap = Map<string, string>; interface RemoteAppMetadata {
category: string;
name?: string;
}
type StoreAppMetadataMap = Map<string, RemoteAppMetadata>;
interface RemoteCategoryAppEntry { interface RemoteCategoryAppEntry {
Name?: string;
Pkgname?: string; Pkgname?: string;
} }
const REMOTE_STORE_BASE_URL = "https://erotica.spark-app.store"; const REMOTE_STORE_BASE_URL = "https://erotica.spark-app.store";
const categoryCache = new Map<string, Promise<StoreCategoryMap>>(); const categoryCache = new Map<string, Promise<StoreAppMetadataMap>>();
const APTSS_LIST_UPGRADABLE_COMMAND = { const APTSS_LIST_UPGRADABLE_COMMAND = {
command: "bash", command: "bash",
@@ -66,6 +73,14 @@ const getApmPrintUrisCommand = (pkgname: string) => ({
], ],
}); });
const getAptssPrintUrisCommand = (pkgname: string) => ({
command: "bash",
args: [
"-lc",
`/usr/bin/apt download ${pkgname} --print-uris -c /opt/durapps/spark-store/bin/apt-fast-conf/aptss-apt.conf -o Dir::Etc::sourcelist=/opt/durapps/spark-store/bin/apt-fast-conf/sources.list.d/aptss.list -o Dir::Etc::sourceparts=/dev/null`,
],
});
const runCommandCapture: UpdateCenterCommandRunner = async ( const runCommandCapture: UpdateCenterCommandRunner = async (
command, command,
args, args,
@@ -140,6 +155,58 @@ const loadApmItemMetadata = async (
}; };
}; };
const loadAptssItemMetadata = async (
item: UpdateCenterItem,
runCommand: UpdateCenterCommandRunner,
): Promise<
| { item: UpdateCenterItem; warning?: undefined }
| { item: null; warning: string }
> => {
console.log(`[DEBUG] Loading APTSS metadata for ${item.pkgname}`);
const printUrisCommand = getAptssPrintUrisCommand(item.pkgname);
console.log(
`[DEBUG] APTSS command: ${printUrisCommand.command} ${printUrisCommand.args.join(" ")}`,
);
const metadataResult = await runCommand(
printUrisCommand.command,
printUrisCommand.args,
);
console.log(`[DEBUG] APTSS metadata result code: ${metadataResult.code}`);
console.log(
`[DEBUG] APTSS metadata stdout: ${metadataResult.stdout.substring(0, 500)}`,
);
console.log(
`[DEBUG] APTSS metadata stderr: ${metadataResult.stderr.substring(0, 500)}`,
);
const commandError = getCommandError(
`aptss metadata query for ${item.pkgname}`,
metadataResult,
);
if (commandError) {
console.log(`[DEBUG] APTSS metadata error: ${commandError}`);
return { item: null, warning: commandError };
}
const metadata = parsePrintUrisOutput(metadataResult.stdout);
console.log(`[DEBUG] APTSS parsed metadata:`, metadata);
if (!metadata) {
return {
item: null,
warning: `aptss metadata query for ${item.pkgname} returned no package metadata`,
};
}
return {
item: {
...item,
...metadata,
},
};
};
const enrichApmItems = async ( const enrichApmItems = async (
items: UpdateCenterItem[], items: UpdateCenterItem[],
runCommand: UpdateCenterCommandRunner, runCommand: UpdateCenterCommandRunner,
@@ -156,6 +223,22 @@ const enrichApmItems = async (
}; };
}; };
const enrichAptssItems = async (
items: UpdateCenterItem[],
runCommand: UpdateCenterCommandRunner,
): Promise<UpdateCenterLoadItemsResult> => {
const results = await Promise.all(
items.map((item) => loadAptssItemMetadata(item, runCommand)),
);
return {
items: results.flatMap((result) => (result.item ? [result.item] : [])),
warnings: results.flatMap((result) =>
result.warning ? [result.warning] : [],
),
};
};
const getStoreArch = ( const getStoreArch = (
item: Pick<UpdateCenterItem, "source" | "arch">, item: Pick<UpdateCenterItem, "source" | "arch">,
): string => { ): string => {
@@ -182,7 +265,7 @@ const loadJson = async <T>(url: string): Promise<T> => {
const loadStoreCategoryMap = async ( const loadStoreCategoryMap = async (
storeArch: string, storeArch: string,
): Promise<StoreCategoryMap> => { ): Promise<StoreAppMetadataMap> => {
const categories = await loadJson<Record<string, unknown>>( const categories = await loadJson<Record<string, unknown>>(
`${REMOTE_STORE_BASE_URL}/${storeArch}/categories.json`, `${REMOTE_STORE_BASE_URL}/${storeArch}/categories.json`,
); );
@@ -196,7 +279,7 @@ const loadStoreCategoryMap = async (
}), }),
); );
const categoryMap: StoreCategoryMap = new Map(); const categoryMap: StoreAppMetadataMap = new Map();
for (const entry of categoryEntries) { for (const entry of categoryEntries) {
if (entry.status !== "fulfilled") { if (entry.status !== "fulfilled") {
continue; continue;
@@ -204,7 +287,10 @@ const loadStoreCategoryMap = async (
for (const app of entry.value.apps) { for (const app of entry.value.apps) {
if (app.Pkgname && !categoryMap.has(app.Pkgname)) { if (app.Pkgname && !categoryMap.has(app.Pkgname)) {
categoryMap.set(app.Pkgname, entry.value.category); categoryMap.set(app.Pkgname, {
category: entry.value.category,
name: app.Name,
});
} }
} }
} }
@@ -212,7 +298,9 @@ const loadStoreCategoryMap = async (
return categoryMap; return categoryMap;
}; };
const getStoreCategoryMap = (storeArch: string): Promise<StoreCategoryMap> => { const getStoreCategoryMap = (
storeArch: string,
): Promise<StoreAppMetadataMap> => {
const cached = categoryCache.get(storeArch); const cached = categoryCache.get(storeArch);
if (cached) { if (cached) {
return cached; return cached;
@@ -241,8 +329,14 @@ const enrichItemCategories = async (
} }
const categoryMap = await getStoreCategoryMap(storeArch); const categoryMap = await getStoreCategoryMap(storeArch);
const category = categoryMap.get(item.pkgname); const metadata = categoryMap.get(item.pkgname);
return category ? { ...item, category } : item; return metadata
? {
...item,
category: metadata.category,
...(metadata.name ? { name: metadata.name } : {}),
}
: item;
}), }),
); );
}; };
@@ -299,18 +393,22 @@ export const loadUpdateCenterItems = async (
enrichItemCategories(aptssItems), enrichItemCategories(aptssItems),
enrichItemCategories(apmItems), enrichItemCategories(apmItems),
]); ]);
const enrichedApmItems = await enrichApmItems( const [enrichedAptssItems, enrichedApmItems] = await Promise.all([
categorizedApmItems, enrichAptssItems(categorizedAptssItems, runCommand),
runCommand, enrichApmItems(categorizedApmItems, runCommand),
); ]);
return { return {
items: mergeUpdateSources( items: mergeUpdateSources(
enrichItemIcons(categorizedAptssItems), enrichItemIcons(enrichedAptssItems.items),
enrichItemIcons(enrichedApmItems.items), enrichItemIcons(enrichedApmItems.items),
installedSources, installedSources,
), ),
warnings: [...warnings, ...enrichedApmItems.warnings], warnings: [
...warnings,
...enrichedAptssItems.warnings,
...enrichedApmItems.warnings,
],
}; };
}; };
@@ -338,8 +436,8 @@ export const registerUpdateCenterIpc = (
"update-center-unignore", "update-center-unignore",
(_event, payload: UpdateCenterIgnorePayload) => service.unignore(payload), (_event, payload: UpdateCenterIgnorePayload) => service.unignore(payload),
); );
ipc.handle("update-center-start", (_event, taskKeys: string[]) => ipc.handle("update-center-start", (_event, tasks: UpdateCenterStartTask[]) =>
service.start(taskKeys), service.start(tasks),
); );
ipc.handle("update-center-cancel", (_event, taskKey: string) => ipc.handle("update-center-cancel", (_event, taskKey: string) =>
service.cancel(taskKey), service.cancel(taskKey),
@@ -360,14 +458,8 @@ export const initializeUpdateCenter = (): UpdateCenterService => {
return updateCenterService; return updateCenterService;
} }
const superUserCmdProvider = async (): Promise<string> => {
const installManager = await import("../install-manager.js");
return installManager.checkSuperUserCommand();
};
updateCenterService = createUpdateCenterService({ updateCenterService = createUpdateCenterService({
loadItems: loadUpdateCenterItems, loadItems: loadUpdateCenterItems,
superUserCmdProvider,
}); });
registerUpdateCenterIpc(ipcMain, updateCenterService); registerUpdateCenterIpc(ipcMain, updateCenterService);
+35 -158
View File
@@ -1,19 +1,12 @@
import { spawn } from "node:child_process";
import { join } from "node:path"; import { join } from "node:path";
import { runAria2Download, type Aria2DownloadResult } from "./download"; import { runAria2Download, type Aria2DownloadResult } from "./download";
import { installPackage } from "../shared-installer";
import type { UpdateCenterQueue, UpdateCenterTask } from "./queue"; import type { UpdateCenterQueue, UpdateCenterTask } from "./queue";
import type { UpdateCenterItem } from "./types"; import type { UpdateCenterItem } from "./types";
const SHELL_CALLER_PATH = "/opt/spark-store/extras/shell-caller.sh";
const SSINSTALL_PATH = "/usr/bin/ssinstall";
const DEFAULT_DOWNLOAD_ROOT = "/tmp/spark-store/update-center"; const DEFAULT_DOWNLOAD_ROOT = "/tmp/spark-store/update-center";
export interface UpdateCommand {
execCommand: string;
execParams: string[];
}
export interface InstallUpdateItemOptions { export interface InstallUpdateItemOptions {
item: UpdateCenterItem; item: UpdateCenterItem;
filePath?: string; filePath?: string;
@@ -55,94 +48,10 @@ export interface CreateTaskRunnerOptions extends TaskRunnerDependencies {
superUserCmd?: string; superUserCmd?: string;
} }
const runCommand = async ( /**
execCommand: string, * 安装更新项
execParams: string[], * 使用与商店安装相同的逻辑
onLog?: (message: string) => void, */
signal?: AbortSignal,
): Promise<void> => {
await new Promise<void>((resolve, reject) => {
const child = spawn(execCommand, execParams, {
shell: false,
env: process.env,
});
const handleOutput = (chunk: Buffer) => {
const message = chunk.toString().trim();
if (message) {
onLog?.(message);
}
};
const abortCommand = () => {
child.kill();
reject(new Error(`Update task cancelled: ${execParams.join(" ")}`));
};
if (signal?.aborted) {
abortCommand();
return;
}
signal?.addEventListener("abort", abortCommand, { once: true });
child.stdout?.on("data", handleOutput);
child.stderr?.on("data", handleOutput);
child.on("error", reject);
child.on("close", (code) => {
signal?.removeEventListener("abort", abortCommand);
if (code === 0) {
resolve();
return;
}
reject(new Error(`${execCommand} exited with code ${code ?? -1}`));
});
});
};
const buildPrivilegedCommand = (
command: string,
args: string[],
superUserCmd?: string,
): UpdateCommand => {
if (superUserCmd) {
return {
execCommand: superUserCmd,
execParams: [command, ...args],
};
}
return {
execCommand: command,
execParams: args,
};
};
export const buildLegacySparkUpgradeCommand = (
pkgname: string,
superUserCmd = "",
): UpdateCommand => {
if (superUserCmd) {
return {
execCommand: superUserCmd,
execParams: [
SHELL_CALLER_PATH,
"aptss",
"install",
"-y",
pkgname,
"--only-upgrade",
],
};
}
return {
execCommand: SHELL_CALLER_PATH,
execParams: ["aptss", "install", "-y", pkgname, "--only-upgrade"],
};
};
export const installUpdateItem = async ({ export const installUpdateItem = async ({
item, item,
filePath, filePath,
@@ -150,45 +59,23 @@ export const installUpdateItem = async ({
onLog, onLog,
signal, signal,
}: InstallUpdateItemOptions): Promise<void> => { }: InstallUpdateItemOptions): Promise<void> => {
if (item.source === "apm" && !filePath) { if (!filePath) {
throw new Error("APM update task requires downloaded package metadata"); throw new Error(
`Update task for ${item.pkgname} requires downloaded package file`,
);
} }
if (item.source === "apm" && filePath) { // 使用与商店安装相同的安装逻辑
const installCommand = buildPrivilegedCommand( const origin = item.source === "apm" ? "apm" : "spark";
SHELL_CALLER_PATH,
["apm", "ssinstall", filePath], await installPackage({
superUserCmd, pkgname: item.pkgname,
); filePath,
await runCommand( origin,
installCommand.execCommand, superUserCmd,
installCommand.execParams, onLog,
onLog, signal,
signal, });
);
return;
}
if (filePath) {
const installCommand = buildPrivilegedCommand(
SSINSTALL_PATH,
[filePath, "--delete-after-install"],
superUserCmd,
);
await runCommand(
installCommand.execCommand,
installCommand.execParams,
onLog,
signal,
);
return;
}
const command = buildLegacySparkUpgradeCommand(
item.pkgname,
superUserCmd ?? "",
);
await runCommand(command.execCommand, command.execParams, onLog, signal);
}; };
export const createTaskRunner = ( export const createTaskRunner = (
@@ -221,11 +108,7 @@ export const createTaskRunner = (
return { return {
cancelActiveTask: () => { cancelActiveTask: () => {
if (!activeAbortController || activeAbortController.signal.aborted) { activeAbortController?.abort();
return;
}
activeAbortController.abort();
}, },
runNextTask: async () => { runNextTask: async () => {
if (inFlightTask) { if (inFlightTask) {
@@ -246,30 +129,24 @@ export const createTaskRunner = (
}; };
try { try {
let filePath: string | undefined; // All updates require download metadata
if (!task.item.downloadUrl || !task.item.fileName) {
if (
task.item.source === "apm" &&
(!task.item.downloadUrl || !task.item.fileName)
) {
throw new Error( throw new Error(
"APM update task requires downloaded package metadata", `Update task for ${task.item.pkgname} requires download metadata (URL and filename)`,
); );
} }
if (task.item.downloadUrl && task.item.fileName) { queue.markActiveTask(task.id, "downloading");
queue.markActiveTask(task.id, "downloading"); const result = await runDownload({
const result = await runDownload({ item: task.item,
item: task.item, task,
task, onLog,
onLog, signal: activeAbortController.signal,
signal: activeAbortController.signal, onProgress: (progress) => {
onProgress: (progress) => { queue.updateTaskProgress(task.id, progress);
queue.updateTaskProgress(task.id, progress); },
}, });
}); const filePath = result.filePath;
filePath = result.filePath;
}
queue.markActiveTask(task.id, "installing"); queue.markActiveTask(task.id, "installing");
await installItem({ await installItem({
+3 -1
View File
@@ -270,7 +270,9 @@ export const parsePrintUrisOutput = (
return null; return null;
} }
const [, downloadUrl, fileName, size, sha512] = match; const [, rawDownloadUrl, fileName, size, sha512] = match;
// Clean up the URL: remove backticks and extra spaces
const downloadUrl = rawDownloadUrl.replace(/[`'"]/g, "").trim();
return { return {
downloadUrl, downloadUrl,
fileName, fileName,
+37 -18
View File
@@ -88,8 +88,8 @@ export const createUpdateCenterQueue = (): UpdateCenterQueue => {
let refreshing = false; let refreshing = false;
let nextTaskId = 1; let nextTaskId = 1;
const getTask = (taskId: number): UpdateCenterTask | undefined => const getTaskIndex = (taskId: number): number =>
tasks.find((task) => task.id === taskId); tasks.findIndex((task) => task.id === taskId);
return { return {
setItems: (nextItems) => { setItems: (nextItems) => {
@@ -117,40 +117,59 @@ export const createUpdateCenterQueue = (): UpdateCenterQueue => {
return task; return task;
}, },
markActiveTask: (taskId, status) => { markActiveTask: (taskId, status) => {
const task = getTask(taskId); const taskIndex = getTaskIndex(taskId);
if (!task) { if (taskIndex === -1) {
return; return;
} }
task.status = status; // 创建新的 task 对象和新的 tasks 数组以触发状态更新
tasks = tasks.map((task, index) =>
index === taskIndex ? { ...task, status } : task,
);
}, },
updateTaskProgress: (taskId, progress) => { updateTaskProgress: (taskId, progress) => {
const task = getTask(taskId); const taskIndex = getTaskIndex(taskId);
if (!task) { if (taskIndex === -1) {
return; return;
} }
task.progress = clampProgress(progress); // 创建新的 task 对象和新的 tasks 数组以触发状态更新
tasks = tasks.map((task, index) =>
index === taskIndex
? { ...task, progress: clampProgress(progress) }
: task,
);
}, },
appendTaskLog: (taskId, message, time = Date.now()) => { appendTaskLog: (taskId, message, time = Date.now()) => {
const task = getTask(taskId); const taskIndex = getTaskIndex(taskId);
if (!task) { if (taskIndex === -1) {
return; return;
} }
task.logs = [...task.logs, { time, message }]; // 创建新的 task 对象和新的 tasks 数组以触发状态更新
tasks = tasks.map((task, index) =>
index === taskIndex
? { ...task, logs: [...task.logs, { time, message }] }
: task,
);
}, },
finishTask: (taskId, status, error) => { finishTask: (taskId, status, error) => {
const task = getTask(taskId); const taskIndex = getTaskIndex(taskId);
if (!task) { if (taskIndex === -1) {
return; return;
} }
task.status = status; // 创建新的 task 对象和新的 tasks 数组以触发状态更新
task.error = error; tasks = tasks.map((task, index) =>
if (status === "completed") { index === taskIndex
task.progress = 100; ? {
} ...task,
status,
error,
progress: status === "completed" ? 100 : task.progress,
}
: task,
);
}, },
getNextQueuedTask: () => tasks.find((task) => task.status === "queued"), getNextQueuedTask: () => tasks.find((task) => task.status === "queued"),
getSnapshot: () => createSnapshot(items, tasks, warnings, refreshing), getSnapshot: () => createSnapshot(items, tasks, warnings, refreshing),
+60 -98
View File
@@ -1,3 +1,4 @@
import { BrowserWindow } from "electron";
import { import {
LEGACY_IGNORE_CONFIG_PATH, LEGACY_IGNORE_CONFIG_PATH,
applyIgnoredEntries, applyIgnoredEntries,
@@ -5,10 +6,8 @@ import {
loadIgnoredEntries, loadIgnoredEntries,
saveIgnoredEntries, saveIgnoredEntries,
} from "./ignore-config"; } from "./ignore-config";
import { createTaskRunner, type UpdateCenterTaskRunner } from "./install";
import { import {
createUpdateCenterQueue, createUpdateCenterQueue,
type UpdateCenterQueue,
type UpdateCenterQueueSnapshot, type UpdateCenterQueueSnapshot,
} from "./queue"; } from "./queue";
import type { UpdateCenterItem, UpdateSource } from "./types"; import type { UpdateCenterItem, UpdateSource } from "./types";
@@ -62,12 +61,17 @@ export interface UpdateCenterIgnorePayload {
newVersion: string; newVersion: string;
} }
export interface UpdateCenterStartTask {
taskKey: string;
id: number;
}
export interface UpdateCenterService { export interface UpdateCenterService {
open: () => Promise<UpdateCenterServiceState>; open: () => Promise<UpdateCenterServiceState>;
refresh: () => Promise<UpdateCenterServiceState>; refresh: () => Promise<UpdateCenterServiceState>;
ignore: (payload: UpdateCenterIgnorePayload) => Promise<void>; ignore: (payload: UpdateCenterIgnorePayload) => Promise<void>;
unignore: (payload: UpdateCenterIgnorePayload) => Promise<void>; unignore: (payload: UpdateCenterIgnorePayload) => Promise<void>;
start: (taskKeys: string[]) => Promise<void>; start: (tasks: UpdateCenterStartTask[]) => Promise<void>;
cancel: (taskKey: string) => Promise<void>; cancel: (taskKey: string) => Promise<void>;
getState: () => UpdateCenterServiceState; getState: () => UpdateCenterServiceState;
subscribe: ( subscribe: (
@@ -79,11 +83,6 @@ export interface CreateUpdateCenterServiceOptions {
loadItems: () => Promise<UpdateCenterItem[] | UpdateCenterLoadedItems>; loadItems: () => Promise<UpdateCenterItem[] | UpdateCenterLoadedItems>;
loadIgnoredEntries?: () => Promise<Set<string>>; loadIgnoredEntries?: () => Promise<Set<string>>;
saveIgnoredEntries?: (entries: ReadonlySet<string>) => Promise<void>; saveIgnoredEntries?: (entries: ReadonlySet<string>) => Promise<void>;
createTaskRunner?: (
queue: UpdateCenterQueue,
superUserCmd?: string,
) => UpdateCenterTaskRunner;
superUserCmdProvider?: () => Promise<string>;
} }
const getTaskKey = ( const getTaskKey = (
@@ -96,7 +95,7 @@ const toState = (
items: snapshot.items.map((item) => ({ items: snapshot.items.map((item) => ({
taskKey: getTaskKey(item), taskKey: getTaskKey(item),
packageName: item.pkgname, packageName: item.pkgname,
displayName: item.pkgname, displayName: item.name || item.pkgname,
currentVersion: item.currentVersion, currentVersion: item.currentVersion,
newVersion: item.nextVersion, newVersion: item.nextVersion,
source: item.source, source: item.source,
@@ -112,19 +111,9 @@ const toState = (
migrationTarget: item.migrationTarget, migrationTarget: item.migrationTarget,
aptssVersion: item.aptssVersion, aptssVersion: item.aptssVersion,
})), })),
tasks: snapshot.tasks.map((task) => ({ tasks: [], // 不再展示任务日志
taskKey: getTaskKey(task.item),
packageName: task.pkgname,
source: task.item.source,
localIcon: task.item.localIcon,
remoteIcon: task.item.remoteIcon,
status: task.status,
progress: task.progress,
logs: task.logs.map((log) => ({ ...log })),
errorMessage: task.error ?? "",
})),
warnings: [...snapshot.warnings], warnings: [...snapshot.warnings],
hasRunningTasks: snapshot.hasRunningTasks, hasRunningTasks: false, // 任务不在更新中心执行
}); });
const normalizeLoadedItems = ( const normalizeLoadedItems = (
@@ -152,12 +141,6 @@ export const createUpdateCenterService = (
options.saveIgnoredEntries ?? options.saveIgnoredEntries ??
((entries: ReadonlySet<string>) => ((entries: ReadonlySet<string>) =>
saveIgnoredEntries(LEGACY_IGNORE_CONFIG_PATH, entries)); saveIgnoredEntries(LEGACY_IGNORE_CONFIG_PATH, entries));
const createRunner =
options.createTaskRunner ??
((currentQueue: UpdateCenterQueue, superUserCmd?: string) =>
createTaskRunner(currentQueue, { superUserCmd }));
let processingPromise: Promise<void> | null = null;
let activeRunner: UpdateCenterTaskRunner | null = null;
const applyWarning = (message: string): void => { const applyWarning = (message: string): void => {
queue.finishRefresh([message]); queue.finishRefresh([message]);
@@ -167,9 +150,9 @@ export const createUpdateCenterService = (
const emit = (): UpdateCenterServiceState => { const emit = (): UpdateCenterServiceState => {
const snapshot = getState(); const snapshot = getState();
for (const listener of listeners) { listeners.forEach((listener) => {
listener(snapshot); listener(snapshot);
} });
return snapshot; return snapshot;
}; };
@@ -192,47 +175,6 @@ export const createUpdateCenterService = (
} }
}; };
const failQueuedTasks = (message: string): void => {
for (const task of queue.getSnapshot().tasks) {
if (task.status === "queued") {
queue.appendTaskLog(task.id, message);
queue.finishTask(task.id, "failed", message);
}
}
};
const ensureProcessing = async (): Promise<void> => {
if (processingPromise) {
return processingPromise;
}
processingPromise = (async () => {
let superUserCmd = "";
try {
superUserCmd = (await options.superUserCmdProvider?.()) ?? "";
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
failQueuedTasks(message);
applyWarning(message);
emit();
return;
}
activeRunner = createRunner(queue, superUserCmd);
while (queue.getNextQueuedTask()) {
await activeRunner.runNextTask();
emit();
}
})().finally(() => {
processingPromise = null;
activeRunner = null;
});
return processingPromise;
};
return { return {
open: refresh, open: refresh,
refresh, refresh,
@@ -248,49 +190,69 @@ export const createUpdateCenterService = (
await saveIgnored(entries); await saveIgnored(entries);
await refresh(); await refresh();
}, },
async start(taskKeys) { async start(tasks) {
const snapshot = queue.getSnapshot(); const snapshot = queue.getSnapshot();
const existingTaskKeys = new Set( const taskIdByKey = new Map(tasks.map((task) => [task.taskKey, task.id]));
snapshot.tasks
.filter(
(task) =>
!["completed", "failed", "cancelled"].includes(task.status),
)
.map((task) => getTaskKey(task.item)),
);
const selectedItems = snapshot.items.filter( const selectedItems = snapshot.items.filter(
(item) => (item) => taskIdByKey.has(getTaskKey(item)) && !item.ignored,
taskKeys.includes(getTaskKey(item)) &&
!item.ignored &&
!existingTaskKeys.has(getTaskKey(item)),
); );
if (selectedItems.length === 0) { if (selectedItems.length === 0) {
return; return;
} }
for (const item of selectedItems) { // 获取主窗口的 webContents
queue.enqueueItem(item); const mainWindow = BrowserWindow.getAllWindows()[0];
} const webContents = mainWindow?.webContents;
emit();
await ensureProcessing(); if (!webContents) {
}, console.error("No main window found");
async cancel(taskKey) {
const task = queue
.getSnapshot()
.tasks.find((entry) => getTaskKey(entry.item) === taskKey);
if (!task) {
return; return;
} }
queue.finishTask(task.id, "cancelled", "Cancelled"); // 获取当前 items
if (["downloading", "installing"].includes(task.status)) { let currentItems = snapshot.items;
activeRunner?.cancelActiveTask();
for (const item of selectedItems) {
const updateTaskId = taskIdByKey.get(getTaskKey(item));
if (updateTaskId === undefined) {
continue;
}
// 构建 metalink URL
const metalinkUrl = item.downloadUrl
? `${item.downloadUrl}.metalink`
: undefined;
// 发送到主下载队列
const installTaskData = {
id: updateTaskId,
pkgname: item.pkgname,
metalinkUrl,
filename: item.fileName,
upgradeOnly: true,
origin: item.source === "apm" ? "apm" : "spark",
retry: false,
};
// 通过 IPC 发送到主下载队列
webContents.send("queue-install", JSON.stringify(installTaskData));
// 从更新中心的 items 中移除该应用(不再显示在更新列表中)
currentItems = currentItems.filter(
(i) => getTaskKey(i) !== getTaskKey(item),
);
} }
// 更新队列中的 items
queue.setItems(currentItems);
emit(); emit();
}, },
async cancel(taskKey) {
// 取消功能不再需要通过更新中心,直接忽略
console.log("Cancel not needed for task:", taskKey);
},
getState, getState,
subscribe(listener) { subscribe(listener) {
listeners.add(listener); listeners.add(listener);
@@ -7,6 +7,7 @@ export interface InstalledSourceState {
export interface UpdateCenterItem { export interface UpdateCenterItem {
pkgname: string; pkgname: string;
name?: string;
source: UpdateSource; source: UpdateSource;
currentVersion: string; currentVersion: string;
nextVersion: string; nextVersion: string;
+1 -1
View File
@@ -220,7 +220,7 @@ ipcMain.handle("open-install-settings", async () => {
const { spawn } = await import("node:child_process"); const { spawn } = await import("node:child_process");
const scriptPath = const scriptPath =
"/opt/durapps/spark-store/bin/update-upgrade/ss-update-controler.sh"; "/opt/durapps/spark-store/bin/update-upgrade/ss-update-controler.sh";
const child = spawn("/opt/spark-store/extras/host-spawn", [scriptPath], { const child = spawn("systemd-run", ["--user", scriptPath], {
detached: true, detached: true,
stdio: "ignore", stdio: "ignore",
}); });
+7 -2
View File
@@ -33,6 +33,11 @@ type UpdateCenterSnapshot = {
hasRunningTasks: boolean; hasRunningTasks: boolean;
}; };
type UpdateCenterStartTask = {
taskKey: string;
id: number;
};
type IpcRendererFacade = { type IpcRendererFacade = {
on: typeof ipcRenderer.on; on: typeof ipcRenderer.on;
off: typeof ipcRenderer.off; off: typeof ipcRenderer.off;
@@ -98,8 +103,8 @@ contextBridge.exposeInMainWorld("updateCenter", {
packageName: string; packageName: string;
newVersion: string; newVersion: string;
}): Promise<void> => ipcRenderer.invoke("update-center-unignore", payload), }): Promise<void> => ipcRenderer.invoke("update-center-unignore", payload),
start: (taskKeys: string[]): Promise<void> => start: (tasks: UpdateCenterStartTask[]): Promise<void> =>
ipcRenderer.invoke("update-center-start", taskKeys), ipcRenderer.invoke("update-center-start", tasks),
cancel: (taskKey: string): Promise<void> => cancel: (taskKey: string): Promise<void> =>
ipcRenderer.invoke("update-center-cancel", taskKey), ipcRenderer.invoke("update-center-cancel", taskKey),
getState: (): Promise<UpdateCenterSnapshot> => getState: (): Promise<UpdateCenterSnapshot> =>
+1 -1
View File
@@ -61,7 +61,7 @@ function launch_app() {
# 提取并净化Exec命令 # 提取并净化Exec命令
exec_command=$(grep -m1 '^Exec=' "$DESKTOP_FILE_PATH" | cut -d= -f2- | sed 's/%.//g') exec_command=$(grep -m1 '^Exec=' "$DESKTOP_FILE_PATH" | cut -d= -f2- | sed 's/%.//g')
[ -z "$exec_command" ] && return 1 [ -z "$exec_command" ] && return 1
[ ! -z "$IS_ACE_ENV" ] && HOST_PREFIX="host-spawn" [ ! -z "$IS_ACE_ENV" ] && HOST_PREFIX="systemd-run --user"
exec_command="${HOST_PREFIX} $exec_command" exec_command="${HOST_PREFIX} $exec_command"
log.info "Launching: $exec_command" log.info "Launching: $exec_command"
${SHELL:-bash} -c " $exec_command" & ${SHELL:-bash} -c " $exec_command" &
+955 -2442
View File
File diff suppressed because it is too large Load Diff
+8 -4
View File
@@ -1,11 +1,14 @@
{ {
"name": "spark-store", "name": "spark-store",
"version": "5.0.0beta3", "version": "5.0.0beta4",
"main": "dist-electron/main/index.js", "main": "dist-electron/main/index.js",
"description": "Client for Spark App Store", "description": "Client for Spark App Store",
"author": "elysia-best <elysia-best@simplelinux.cn.eu.org>", "author": "elysia-best <elysia-best@simplelinux.cn.eu.org>",
"license": "GPL-3.0", "license": "GPL-3.0",
"private": true, "private": true,
"engines": {
"node": ">=22.12.0"
},
"keywords": [ "keywords": [
"electron", "electron",
"rollup", "rollup",
@@ -14,6 +17,7 @@
"vue", "vue",
"spark-app-store" "spark-app-store"
], ],
"homepage": "https://spark-app.store",
"debug": { "debug": {
"env": { "env": {
"VITE_DEV_SERVER_URL": "http://127.0.0.1:3344/" "VITE_DEV_SERVER_URL": "http://127.0.0.1:3344/"
@@ -43,12 +47,12 @@
"@dotenvx/dotenvx": "^1.51.4", "@dotenvx/dotenvx": "^1.51.4",
"@eslint/create-config": "^1.11.0", "@eslint/create-config": "^1.11.0",
"@eslint/js": "^9.39.2", "@eslint/js": "^9.39.2",
"@loongdotjs/electron-builder": "^26.0.12-1", "@loongdotjs/electron-builder": "^26.8.2-loong1",
"@playwright/test": "^1.40.0", "@playwright/test": "^1.40.0",
"@testing-library/jest-dom": "^6.1.5", "@testing-library/jest-dom": "^6.1.5",
"@testing-library/vue": "^8.0.1", "@testing-library/vue": "^8.0.1",
"@vitejs/plugin-vue": "^6.0.3", "@vitejs/plugin-vue": "^6.0.3",
"@vitest/coverage-v8": "^1.0.0", "@vitest/coverage-v8": "^4.1.4",
"@vue/test-utils": "^2.4.3", "@vue/test-utils": "^2.4.3",
"conventional-changelog": "^7.1.1", "conventional-changelog": "^7.1.1",
"conventional-changelog-angular": "^8.1.0", "conventional-changelog-angular": "^8.1.0",
@@ -67,7 +71,7 @@
"vite": "^6.4.1", "vite": "^6.4.1",
"vite-plugin-electron": "^0.29.0", "vite-plugin-electron": "^0.29.0",
"vite-plugin-electron-renderer": "^0.14.5", "vite-plugin-electron-renderer": "^0.14.5",
"vitest": "^1.0.0", "vitest": "^4.1.4",
"vue": "^3.4.21", "vue": "^3.4.21",
"vue-tsc": "^3.2.4" "vue-tsc": "^3.2.4"
}, },
+13 -32
View File
@@ -182,6 +182,10 @@ import {
removeDownloadItem, removeDownloadItem,
watchDownloadsChange, watchDownloadsChange,
} from "./global/downloadStatus"; } from "./global/downloadStatus";
import {
countSearchMatchesByCategory,
rankAppsBySearch,
} from "./modules/appSearch";
import { handleInstall, handleRetry } from "./modules/processInstall"; import { handleInstall, handleRetry } from "./modules/processInstall";
import { createUpdateCenterStore } from "./modules/updateCenter"; import { createUpdateCenterStore } from "./modules/updateCenter";
import type { import type {
@@ -260,7 +264,7 @@ const apmAvailable = ref(false);
const storeFilter = ref<"spark" | "apm" | "both">("both"); const storeFilter = ref<"spark" | "apm" | "both">("both");
// 计算属性 // 计算属性
const filteredApps = computed(() => { const baseApps = computed(() => {
let result = [...apps.value]; let result = [...apps.value];
// 合并相同包名的应用 (混合模式) // 合并相同包名的应用 (混合模式)
@@ -284,50 +288,27 @@ const filteredApps = computed(() => {
result = Array.from(mergedMap.values()); result = Array.from(mergedMap.values());
} }
return result;
});
const filteredApps = computed(() => {
let result = [...baseApps.value];
// 按分类筛选 // 按分类筛选
if (activeCategory.value !== "all") { if (activeCategory.value !== "all") {
result = result.filter((app) => app.category === activeCategory.value); result = result.filter((app) => app.category === activeCategory.value);
} }
// 按搜索关键词筛选
if (searchQuery.value.trim()) { if (searchQuery.value.trim()) {
const q = searchQuery.value.toLowerCase().trim(); return rankAppsBySearch(result, searchQuery.value);
result = result.filter((app) => {
// 兼容可能为 undefined 的情况,虽然类型定义是 string
return (
(app.name || "").toLowerCase().includes(q) ||
(app.pkgname || "").toLowerCase().includes(q) ||
(app.tags || "").toLowerCase().includes(q) ||
(app.more || "").toLowerCase().includes(q)
);
});
} }
return result; return result;
}); });
const categoryCounts = computed(() => { const categoryCounts = computed(() => {
// 如果有搜索关键词,显示搜索结果数量
if (searchQuery.value.trim()) { if (searchQuery.value.trim()) {
const q = searchQuery.value.toLowerCase().trim(); return countSearchMatchesByCategory(baseApps.value, searchQuery.value);
const counts: Record<string, number> = { all: 0 };
apps.value.forEach((app) => {
// 检查应用是否匹配搜索条件
const matches =
(app.name || "").toLowerCase().includes(q) ||
(app.pkgname || "").toLowerCase().includes(q) ||
(app.tags || "").toLowerCase().includes(q) ||
(app.more || "").toLowerCase().includes(q);
if (matches) {
counts.all++;
if (!counts[app.category]) counts[app.category] = 0;
counts[app.category]++;
}
});
return counts;
} }
// 无搜索时显示总数量 // 无搜索时显示总数量
@@ -0,0 +1,25 @@
import { render, screen } from "@testing-library/vue";
import { describe, expect, it } from "vitest";
import InstalledAppsModal from "@/components/InstalledAppsModal.vue";
describe("InstalledAppsModal", () => {
it("keeps scroll chaining inside the modal list", () => {
const { container } = render(InstalledAppsModal, {
props: {
show: true,
apps: [],
loading: false,
error: "",
activeOrigin: "spark",
storeFilter: "both",
apmAvailable: true,
},
});
expect(screen.getByText("已安装应用")).toBeTruthy();
const scrollContainer = container.querySelector(".overflow-y-auto");
expect(scrollContainer?.className).toContain("overscroll-contain");
});
});
+148
View File
@@ -0,0 +1,148 @@
import { describe, expect, it } from "vitest";
import {
countSearchMatchesByCategory,
getSearchMatchScore,
matchesSearch,
rankAppsBySearch,
} from "@/modules/appSearch";
import type { App } from "@/global/typedefinition";
const createApp = (
name: string,
pkgname: string,
overrides: Partial<App> = {},
): App => ({
name,
pkgname,
version: "1.0.0",
filename: "app.deb",
torrent_address: "",
author: "",
contributor: "",
website: "",
update: "",
size: "1 MB",
more: "",
tags: "",
img_urls: [],
icons: "",
category: "tools",
origin: "spark",
currentStatus: "not-installed",
...overrides,
});
describe("app search score", () => {
it("scores a name match above a description-only match", () => {
const byName = createApp("维护打包工具箱", "uos-packaging-tools");
const byMore = createApp("QQ", "linuxqq", {
more: "用于系统维护的聊天软件",
});
expect(getSearchMatchScore(byName, "维护")).toBeGreaterThan(
getSearchMatchScore(byMore, "维护"),
);
});
it("scores a name prefix match above a name contains match", () => {
const prefix = createApp("维护打包工具箱", "toolbox");
const contains = createApp("桌面维护助手", "desktop-maintainer");
expect(getSearchMatchScore(prefix, "维护")).toBeGreaterThan(
getSearchMatchScore(contains, "维护"),
);
});
it("scores a name exact match above a name prefix match", () => {
const exact = createApp("维护", "exact-match");
const prefix = createApp("维护打包工具箱", "prefix-match");
expect(getSearchMatchScore(exact, "维护")).toBeGreaterThan(
getSearchMatchScore(prefix, "维护"),
);
});
it("scores a pkgname match above tags and description matches", () => {
const byPkgname = createApp("工具箱", "maintenance-toolbox");
const byTags = createApp("应用 A", "app-a", { tags: "maintenance;tools" });
const byMore = createApp("应用 B", "app-b", {
more: "maintenance related guide",
});
expect(getSearchMatchScore(byPkgname, "maintenance")).toBeGreaterThan(
getSearchMatchScore(byTags, "maintenance"),
);
expect(getSearchMatchScore(byPkgname, "maintenance")).toBeGreaterThan(
getSearchMatchScore(byMore, "maintenance"),
);
});
it("matches only against the normalized literal query", () => {
const app = createApp("Toolbox", "maintenance-toolbox", {
tags: "maintenance;tools",
more: "maintenance related guide",
});
expect(getSearchMatchScore(app, "维护")).toBe(0);
expect(matchesSearch(app, "维护")).toBe(false);
});
it("reports whether an app matches the query", () => {
const matched = createApp("维护打包工具箱", "uos-packaging-tools");
const ignored = createApp("Firefox", "firefox-spark", {
more: "浏览器",
});
expect(matchesSearch(matched, "维护")).toBe(true);
expect(matchesSearch(ignored, "维护")).toBe(false);
});
it("ranks apps in name, pkgname, tags, then description order", () => {
const byName = createApp("maintenance 打包工具箱", "uos-packaging-tools");
const byPkgname = createApp("工具箱", "maintenance-toolbox");
const byTags = createApp("应用 A", "app-a", { tags: "maintenance;tool" });
const byMore = createApp("QQ", "linuxqq", {
more: "maintenance related chat software",
});
const nonMatch = createApp("Firefox", "firefox", {
more: "browser",
});
expect(
rankAppsBySearch(
[byMore, nonMatch, byTags, byPkgname, byName],
"maintenance",
).map((app) => app.pkgname),
).toEqual([
"uos-packaging-tools",
"maintenance-toolbox",
"app-a",
"linuxqq",
]);
});
it("keeps original order when scores tie and counts matches by category", () => {
const first = createApp("maintenance tool A", "maint-a", {
category: "tools",
});
const second = createApp("maintenance tool B", "maint-b", {
category: "tools",
});
const browser = createApp("Firefox", "firefox", {
category: "internet",
more: "browser",
});
expect(rankAppsBySearch([first, second], "maintenance")).toEqual([
first,
second,
]);
expect(
countSearchMatchesByCategory([first, second, browser], "maintenance"),
).toEqual({
all: 2,
tools: 2,
});
});
});
+36
View File
@@ -0,0 +1,36 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
describe("processInstall queue forwarding", () => {
beforeEach(() => {
vi.resetModules();
});
it("forwards update-center queue-install events back to the main install queue", async () => {
const handlers = new Map<string, (...args: unknown[]) => void>();
const send = vi.fn();
const on = vi.fn(
(channel: string, handler: (...args: unknown[]) => void) => {
handlers.set(channel, handler);
},
);
Object.assign(window.ipcRenderer, {
on,
send,
invoke: vi.fn(),
});
await import("@/modules/processInstall");
const payload = JSON.stringify({
id: 7,
pkgname: "spark-weather",
origin: "spark",
upgradeOnly: true,
});
handlers.get("queue-install")?.({}, payload);
expect(send).toHaveBeenCalledWith("queue-install", payload);
});
});
@@ -19,6 +19,12 @@ const DPKG_QUERY_INSTALLED_KEY =
const APM_PRINT_URIS_KEY = const APM_PRINT_URIS_KEY =
"bash -lc amber-pm-debug /usr/bin/apt -c /opt/durapps/spark-store/bin/apt-fast-conf/aptss-apt.conf download spark-weather --print-uris"; "bash -lc amber-pm-debug /usr/bin/apt -c /opt/durapps/spark-store/bin/apt-fast-conf/aptss-apt.conf download spark-weather --print-uris";
const APTSS_WEATHER_PRINT_URIS_KEY =
"bash -lc /usr/bin/apt download spark-weather --print-uris -c /opt/durapps/spark-store/bin/apt-fast-conf/aptss-apt.conf -o Dir::Etc::sourcelist=/opt/durapps/spark-store/bin/apt-fast-conf/sources.list.d/aptss.list -o Dir::Etc::sourceparts=/dev/null";
const APTSS_NOTES_PRINT_URIS_KEY =
"bash -lc /usr/bin/apt download spark-notes --print-uris -c /opt/durapps/spark-store/bin/apt-fast-conf/aptss-apt.conf -o Dir::Etc::sourcelist=/opt/durapps/spark-store/bin/apt-fast-conf/sources.list.d/aptss.list -o Dir::Etc::sourceparts=/dev/null";
const loadUpdateCenterModule = async ( const loadUpdateCenterModule = async (
remoteStore: Record<string, RemoteStoreResponse>, remoteStore: Record<string, RemoteStoreResponse>,
) => { ) => {
@@ -141,19 +147,28 @@ describe("update-center load items", () => {
stderr: "", stderr: "",
}, },
], ],
[
APTSS_WEATHER_PRINT_URIS_KEY,
{
code: 0,
stdout:
"'https://example.invalid/spark-weather_2.0.0_amd64.deb' spark-weather_2.0.0_amd64.deb 123456 SHA512:deadbeef",
stderr: "",
},
],
]); ]);
const { loadUpdateCenterItems } = await loadUpdateCenterModule({ const { loadUpdateCenterItems } = await loadUpdateCenterModule({
"https://erotica.spark-app.store/amd64-store/categories.json": { "https://erotica.spark-app.store/amd64-store/categories.json": {
tools: { zh: "Tools" }, tools: { zh: "Tools" },
}, },
"https://erotica.spark-app.store/amd64-store/tools/applist.json": [ "https://erotica.spark-app.store/amd64-store/tools/applist.json": [
{ Pkgname: "spark-weather" }, { Name: "Spark Weather", Pkgname: "spark-weather" },
], ],
"https://erotica.spark-app.store/amd64-apm/categories.json": { "https://erotica.spark-app.store/amd64-apm/categories.json": {
tools: { zh: "Tools" }, tools: { zh: "Tools" },
}, },
"https://erotica.spark-app.store/amd64-apm/tools/applist.json": [ "https://erotica.spark-app.store/amd64-apm/tools/applist.json": [
{ Pkgname: "spark-weather" }, { Name: "Spark Weather", Pkgname: "spark-weather" },
], ],
}); });
@@ -175,6 +190,7 @@ describe("update-center load items", () => {
nextVersion: "3.0.0", nextVersion: "3.0.0",
arch: "amd64", arch: "amd64",
category: "tools", category: "tools",
name: "Spark Weather",
remoteIcon: remoteIcon:
"https://erotica.spark-app.store/amd64-apm/tools/spark-weather/icon.png", "https://erotica.spark-app.store/amd64-apm/tools/spark-weather/icon.png",
downloadUrl: "https://example.invalid/spark-weather_3.0.0_amd64.deb", downloadUrl: "https://example.invalid/spark-weather_3.0.0_amd64.deb",
@@ -194,7 +210,7 @@ describe("update-center load items", () => {
office: { zh: "Office" }, office: { zh: "Office" },
}, },
"https://erotica.spark-app.store/amd64-store/office/applist.json": [ "https://erotica.spark-app.store/amd64-store/office/applist.json": [
{ Pkgname: "spark-notes" }, { Name: "Spark Notes", Pkgname: "spark-notes" },
], ],
}); });
@@ -217,6 +233,15 @@ describe("update-center load items", () => {
}; };
} }
if (key === APTSS_NOTES_PRINT_URIS_KEY) {
return {
code: 0,
stdout:
"'https://example.invalid/spark-notes_2.0.0_amd64.deb' spark-notes_2.0.0_amd64.deb 654321 SHA512:beadfeed",
stderr: "",
};
}
if (key === "apm list --upgradable" || key === "apm list --installed") { if (key === "apm list --upgradable" || key === "apm list --installed") {
return { return {
code: 127, code: 127,
@@ -236,8 +261,13 @@ describe("update-center load items", () => {
nextVersion: "2.0.0", nextVersion: "2.0.0",
arch: "amd64", arch: "amd64",
category: "office", category: "office",
name: "Spark Notes",
remoteIcon: remoteIcon:
"https://erotica.spark-app.store/amd64-store/office/spark-notes/icon.png", "https://erotica.spark-app.store/amd64-store/office/spark-notes/icon.png",
downloadUrl: "https://example.invalid/spark-notes_2.0.0_amd64.deb",
fileName: "spark-notes_2.0.0_amd64.deb",
size: 654321,
sha512: "beadfeed",
}, },
]); ]);
expect(result.warnings).toEqual([ expect(result.warnings).toEqual([
@@ -289,6 +319,7 @@ describe("update-center load items", () => {
currentVersion: "1.0.0", currentVersion: "1.0.0",
nextVersion: "2.0.0", nextVersion: "2.0.0",
arch: "amd64", arch: "amd64",
name: "Spark Notes",
}, },
]); ]);
@@ -298,7 +329,7 @@ describe("update-center load items", () => {
}; };
remoteStore[ remoteStore[
"https://erotica.spark-app.store/amd64-store/office/applist.json" "https://erotica.spark-app.store/amd64-store/office/applist.json"
] = [{ Pkgname: "spark-notes" }]; ] = [{ Name: "Spark Notes", Pkgname: "spark-notes" }];
const secondResult = await loadUpdateCenterItems(runCommand); const secondResult = await loadUpdateCenterItems(runCommand);
@@ -310,6 +341,7 @@ describe("update-center load items", () => {
nextVersion: "2.0.0", nextVersion: "2.0.0",
arch: "amd64", arch: "amd64",
category: "office", category: "office",
name: "Spark Notes",
remoteIcon: remoteIcon:
"https://erotica.spark-app.store/amd64-store/office/spark-notes/icon.png", "https://erotica.spark-app.store/amd64-store/office/spark-notes/icon.png",
}, },
@@ -323,7 +355,7 @@ describe("update-center load items", () => {
tools: { zh: "Tools" }, tools: { zh: "Tools" },
}, },
"https://erotica.spark-app.store/amd64-store/office/applist.json": [ "https://erotica.spark-app.store/amd64-store/office/applist.json": [
{ Pkgname: "spark-notes" }, { Name: "Spark Notes", Pkgname: "spark-notes" },
], ],
}); });
@@ -346,6 +378,15 @@ describe("update-center load items", () => {
}; };
} }
if (key === APTSS_NOTES_PRINT_URIS_KEY) {
return {
code: 0,
stdout:
"'https://example.invalid/spark-notes_2.0.0_amd64.deb' spark-notes_2.0.0_amd64.deb 654321 SHA512:beadfeed",
stderr: "",
};
}
if (key === "apm list --upgradable" || key === "apm list --installed") { if (key === "apm list --upgradable" || key === "apm list --installed") {
return { return {
code: 127, code: 127,
@@ -365,6 +406,7 @@ describe("update-center load items", () => {
nextVersion: "2.0.0", nextVersion: "2.0.0",
arch: "amd64", arch: "amd64",
category: "office", category: "office",
name: "Spark Notes",
remoteIcon: remoteIcon:
"https://erotica.spark-app.store/amd64-store/office/spark-notes/icon.png", "https://erotica.spark-app.store/amd64-store/office/spark-notes/icon.png",
}, },
@@ -262,6 +262,27 @@ describe("update-center/ipc", () => {
await startPromise; await startPromise;
}); });
it("service item snapshots prefer resolved app names over package names", async () => {
const service = createUpdateCenterService({
loadItems: async () => [
{
...createItem(),
name: "Spark Weather",
},
],
});
const snapshot = await service.refresh();
expect(snapshot.items).toMatchObject([
{
taskKey: "aptss:spark-weather",
packageName: "spark-weather",
displayName: "Spark Weather",
},
]);
});
it("concurrent start calls still serialize through one processing pipeline", async () => { it("concurrent start calls still serialize through one processing pipeline", async () => {
const startedTaskIds: number[] = []; const startedTaskIds: number[] = [];
const releases: Array<() => void> = []; const releases: Array<() => void> = [];
@@ -0,0 +1,53 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createUpdateCenterService } from "../../../../electron/main/backend/update-center/service";
const electronMock = vi.hoisted(() => ({
getAllWindows: vi.fn(),
}));
vi.mock("electron", () => ({
BrowserWindow: {
getAllWindows: electronMock.getAllWindows,
},
}));
describe("update-center service id forwarding", () => {
beforeEach(() => {
electronMock.getAllWindows.mockReset();
});
it("forwards renderer-assigned ids into queue-install payloads", async () => {
const send = vi.fn();
electronMock.getAllWindows.mockReturnValue([{ webContents: { send } }]);
const service = createUpdateCenterService({
loadItems: async () => [
{
pkgname: "spark-weather",
source: "aptss",
currentVersion: "1.0.0",
nextVersion: "2.0.0",
fileName: "spark-weather.deb",
downloadUrl: "https://example.com/spark-weather.deb",
},
],
});
await service.refresh();
await service.start([{ taskKey: "aptss:spark-weather", id: -1 }]);
expect(send).toHaveBeenCalledWith(
"queue-install",
JSON.stringify({
id: -1,
pkgname: "spark-weather",
metalinkUrl: "https://example.com/spark-weather.deb.metalink",
filename: "spark-weather.deb",
upgradeOnly: true,
origin: "spark",
retry: false,
}),
);
});
});
+75 -1
View File
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { createUpdateCenterStore } from "@/modules/updateCenter"; import { createUpdateCenterStore } from "@/modules/updateCenter";
import { downloads } from "@/global/downloadStatus";
const createSnapshot = (overrides = {}) => ({ const createSnapshot = (overrides = {}) => ({
items: [ items: [
@@ -33,6 +34,7 @@ describe("updateCenter store", () => {
start.mockReset(); start.mockReset();
onState.mockReset(); onState.mockReset();
offState.mockReset(); offState.mockReset();
downloads.value = [];
Object.defineProperty(window, "updateCenter", { Object.defineProperty(window, "updateCenter", {
configurable: true, configurable: true,
@@ -94,7 +96,79 @@ describe("updateCenter store", () => {
store.toggleSelection("apm:spark-clock"); store.toggleSelection("apm:spark-clock");
await store.startSelected(); await store.startSelected();
expect(start).toHaveBeenCalledWith(["aptss:spark-weather"]); expect(start).toHaveBeenCalledWith([
{
taskKey: "aptss:spark-weather",
id: downloads.value[0]?.id,
},
]);
});
it("uses remoteIcon when adding update tasks to the download queue", async () => {
const snapshot = createSnapshot({
items: [
{
taskKey: "aptss:spark-weather",
packageName: "spark-weather",
displayName: "Spark Weather",
currentVersion: "1.0.0",
newVersion: "2.0.0",
source: "aptss" as const,
ignored: false,
remoteIcon: "https://example.com/icons/spark-weather.png",
},
],
});
open.mockResolvedValue(snapshot);
const store = createUpdateCenterStore();
await store.open();
store.toggleSelection("aptss:spark-weather");
await store.startSelected();
expect(downloads.value).toHaveLength(1);
expect(downloads.value[0]?.icon).toBe(
"https://example.com/icons/spark-weather.png",
);
});
it("passes the renderer download id through to update-center start", async () => {
downloads.value = [
{
id: 5,
name: "Spark Notes",
pkgname: "spark-notes",
version: "1.0.0",
icon: "https://example.com/icons/spark-notes.png",
origin: "spark",
status: "queued",
progress: 0,
downloadedSize: 0,
totalSize: 1024,
speed: 0,
timeRemaining: 0,
startTime: Date.now(),
logs: [],
source: "APM Store",
retry: false,
},
];
const snapshot = createSnapshot();
open.mockResolvedValue(snapshot);
const store = createUpdateCenterStore();
await store.open();
store.toggleSelection("aptss:spark-weather");
await store.startSelected();
expect(downloads.value).toHaveLength(2);
expect(downloads.value[1]?.id).toBeLessThan(0);
expect(start).toHaveBeenCalledWith([
{
taskKey: "aptss:spark-weather",
id: downloads.value[1]?.id,
},
]);
}); });
it("blocks close requests while the snapshot reports running tasks", () => { it("blocks close requests while the snapshot reports running tasks", () => {
@@ -3,7 +3,6 @@ import { describe, expect, it, vi } from "vitest";
import type { UpdateCenterItem } from "../../../../electron/main/backend/update-center/types"; import type { UpdateCenterItem } from "../../../../electron/main/backend/update-center/types";
import { import {
createTaskRunner, createTaskRunner,
buildLegacySparkUpgradeCommand,
installUpdateItem, installUpdateItem,
} from "../../../../electron/main/backend/update-center/install"; } from "../../../../electron/main/backend/update-center/install";
import { createUpdateCenterQueue } from "../../../../electron/main/backend/update-center/queue"; import { createUpdateCenterQueue } from "../../../../electron/main/backend/update-center/queue";
@@ -114,22 +113,6 @@ describe("update-center task runner", () => {
}); });
}); });
it("returns a direct aptss upgrade command instead of spark-update-tool", () => {
expect(
buildLegacySparkUpgradeCommand("spark-weather", "/usr/bin/pkexec"),
).toEqual({
execCommand: "/usr/bin/pkexec",
execParams: [
"/opt/spark-store/extras/shell-caller.sh",
"aptss",
"install",
"-y",
"spark-weather",
"--only-upgrade",
],
});
});
it("blocks close while a refresh or task is still running", () => { it("blocks close while a refresh or task is still running", () => {
const queue = createUpdateCenterQueue(); const queue = createUpdateCenterQueue();
const item = createAptssItem(); const item = createAptssItem();
@@ -208,7 +191,7 @@ describe("update-center task runner", () => {
{ {
id: task.id, id: task.id,
status: "failed", status: "failed",
error: "APM update task requires downloaded package metadata", error: "Update task for spark-player requires download metadata (URL and filename)",
}, },
], ],
}); });
@@ -300,4 +283,28 @@ describe("update-center task runner", () => {
}, },
]); ]);
}); });
it("uses ssinstall for aptss (spark) file installs", async () => {
childProcessMock.spawnCalls.length = 0;
await installUpdateItem({
item: createAptssItem(),
filePath: "/tmp/spark-weather.deb",
superUserCmd: "/usr/bin/pkexec",
});
expect(childProcessMock.spawnCalls).toEqual([
{
command: "/usr/bin/pkexec",
args: [
"/opt/spark-store/extras/shell-caller.sh",
"ssinstall",
"/tmp/spark-weather.deb",
"--delete-after-install",
"--no-create-desktop-entry",
"--native",
],
},
]);
});
}); });
+10 -3
View File
@@ -12,9 +12,10 @@
v-bind="attrs" v-bind="attrs"
class="fixed inset-0 z-50 flex items-center justify-center overflow-hidden bg-slate-900/70 p-4" class="fixed inset-0 z-50 flex items-center justify-center overflow-hidden bg-slate-900/70 p-4"
@click.self="closeModal" @click.self="closeModal"
@wheel="onOverlayWheel"
> >
<div <div
class="modal-panel relative w-full max-w-5xl max-h-[85vh] overflow-y-auto scrollbar-nowidth rounded-3xl border border-white/10 bg-white/95 px-6 pb-6 shadow-2xl dark:border-slate-800 dark:bg-slate-900" class="modal-panel relative w-full max-w-5xl max-h-[85vh] overflow-y-auto overscroll-contain scrollbar-nowidth rounded-3xl border border-white/10 bg-white/95 px-6 pb-6 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
> >
<!-- 返回按钮 - sticky定位在模态框内部左上角滚动时始终可见 --> <!-- 返回按钮 - sticky定位在模态框内部左上角滚动时始终可见 -->
<button <button
@@ -276,7 +277,7 @@
应用详情 应用详情
</h3> </h3>
<div <div
class="max-h-48 overflow-y-auto text-sm leading-relaxed text-slate-600 dark:text-slate-300 space-y-2" class="max-h-48 overflow-y-auto overscroll-contain text-sm leading-relaxed text-slate-600 dark:text-slate-300 space-y-2"
v-html="displayApp.more.replace(/\n/g, '<br>')" v-html="displayApp.more.replace(/\n/g, '<br>')"
></div> ></div>
</div> </div>
@@ -351,7 +352,7 @@
应用信息 应用信息
</h3> </h3>
<div <div
class="max-h-80 overflow-y-auto rounded-xl bg-slate-50 p-4 dark:bg-slate-900/50 space-y-3" class="max-h-80 overflow-y-auto overscroll-contain rounded-xl bg-slate-50 p-4 dark:bg-slate-900/50 space-y-3"
> >
<div v-if="displayApp?.name" class="flex justify-between"> <div v-if="displayApp?.name" class="flex justify-between">
<span class="text-sm text-slate-500">应用名称</span> <span class="text-sm text-slate-500">应用名称</span>
@@ -629,4 +630,10 @@ const openPreview = (index: number) => {
const hideImage = (e: Event) => { const hideImage = (e: Event) => {
(e.target as HTMLElement).style.display = "none"; (e.target as HTMLElement).style.display = "none";
}; };
const onOverlayWheel = (e: WheelEvent) => {
const target = e.target as HTMLElement;
if (target.closest(".overflow-y-auto, .overflow-auto")) return;
e.preventDefault();
};
</script> </script>
+9 -2
View File
@@ -11,9 +11,10 @@
v-if="show" v-if="show"
class="fixed inset-0 z-50 flex items-start justify-center bg-slate-900/70 px-4 py-10" class="fixed inset-0 z-50 flex items-start justify-center bg-slate-900/70 px-4 py-10"
@click="handleOverlayClick" @click="handleOverlayClick"
@wheel="onOverlayWheel"
> >
<div <div
class="scrollbar-nowidth scrollbar-thumb-slate-200 dark:scrollbar-thumb-slate-700 scrollbar-track-transparent w-full max-w-2xl max-h-[85vh] overflow-y-auto rounded-3xl border border-white/10 bg-white/95 p-6 shadow-2xl dark:border-slate-800 dark:bg-slate-900" class="scrollbar-nowidth scrollbar-thumb-slate-200 dark:scrollbar-thumb-slate-700 scrollbar-track-transparent w-full max-w-2xl max-h-[85vh] overflow-y-auto overscroll-contain rounded-3xl border border-white/10 bg-white/95 p-6 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
@click.stop @click.stop
> >
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
@@ -154,7 +155,7 @@
</button> </button>
</div> </div>
<div <div
class="max-h-48 space-y-2 overflow-y-auto rounded-2xl bg-slate-50/80 p-3 font-mono text-xs text-slate-600 dark:bg-slate-900/60 dark:text-slate-300" class="max-h-48 space-y-2 overflow-y-auto overscroll-contain rounded-2xl bg-slate-50/80 p-3 font-mono text-xs text-slate-600 dark:bg-slate-900/60 dark:text-slate-300"
> >
<div <div
v-for="(log, index) in download.logs" v-for="(log, index) in download.logs"
@@ -310,4 +311,10 @@ const copyLogs = () => {
const downloadProgress = computed(() => { const downloadProgress = computed(() => {
return props.download ? Math.floor(props.download.progress * 100) : 0; return props.download ? Math.floor(props.download.progress * 100) : 0;
}); });
const onOverlayWheel = (e: WheelEvent) => {
const target = e.target as HTMLElement;
if (target.closest(".overflow-y-auto, .overflow-auto")) return;
e.preventDefault();
};
</script> </script>
+4 -1
View File
@@ -49,7 +49,10 @@
leave-from-class="opacity-100 translate-y-0" leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 -translate-y-2" leave-to-class="opacity-0 -translate-y-2"
> >
<div v-show="isExpanded" class="max-h-96 overflow-y-auto px-3 pb-4"> <div
v-show="isExpanded"
class="max-h-96 overflow-y-auto overscroll-contain px-3 pb-4"
>
<div <div
v-if="downloads.length === 0" v-if="downloads.length === 0"
class="flex flex-col items-center justify-center rounded-2xl border border-dashed border-slate-200/80 px-4 py-12 text-slate-500 dark:border-slate-800/80 dark:text-slate-400" class="flex flex-col items-center justify-center rounded-2xl border border-dashed border-slate-200/80 px-4 py-12 text-slate-500 dark:border-slate-800/80 dark:text-slate-400"
+8 -1
View File
@@ -11,6 +11,7 @@
v-if="show" v-if="show"
class="fixed inset-0 z-50 flex items-start justify-center bg-slate-900/70 px-4 py-10" class="fixed inset-0 z-50 flex items-start justify-center bg-slate-900/70 px-4 py-10"
@click.self="$emit('close')" @click.self="$emit('close')"
@wheel="onOverlayWheel"
> >
<div <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 rounded-3xl border border-white/10 bg-white/95 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
@@ -78,7 +79,7 @@
</div> </div>
<div <div
class="flex-1 overflow-y-auto scrollbar-nowidth scrollbar-thumb-slate-200 dark:scrollbar-thumb-slate-700 p-6 space-y-4" class="flex-1 overflow-y-auto overscroll-contain scrollbar-nowidth scrollbar-thumb-slate-200 dark:scrollbar-thumb-slate-700 p-6 space-y-4"
> >
<div <div
v-if="loading" v-if="loading"
@@ -193,4 +194,10 @@ defineEmits<{
(e: "uninstall", app: App): void; (e: "uninstall", app: App): void;
(e: "switch-origin", origin: "apm" | "spark"): void; (e: "switch-origin", origin: "apm" | "spark"): void;
}>(); }>();
const onOverlayWheel = (e: WheelEvent) => {
const target = e.target as HTMLElement;
if (target.closest(".overflow-y-auto, .overflow-auto")) return;
e.preventDefault();
};
</script> </script>
+17 -13
View File
@@ -10,16 +10,22 @@
<div <div
v-if="show" v-if="show"
class="fixed inset-0 z-50 flex items-start justify-center bg-slate-900/70 px-4 py-6 lg:py-10" class="fixed inset-0 z-50 flex items-start justify-center bg-slate-900/70 px-4 py-6 lg:py-10"
@wheel="onOverlayWheel"
@click="onOverlayClick"
> >
<div <div
class="flex max-h-[90vh] w-full max-w-6xl flex-col overflow-hidden rounded-3xl border border-white/10 bg-white/95 shadow-2xl dark:border-slate-800 dark:bg-slate-900" class="flex max-h-[90vh] w-full max-w-6xl flex-col overflow-hidden rounded-3xl border border-white/10 bg-white/95 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
@click.stop
> >
<UpdateCenterToolbar <UpdateCenterToolbar
:search-query="store.searchQuery.value" :search-query="store.searchQuery.value"
:selected-count="selectedCount" :selected-count="selectedCount"
:all-selected="store.allSelected.value"
:some-selected="store.someSelected.value"
@refresh="store.refresh" @refresh="store.refresh"
@start-selected="emit('request-start-selected')" @start-selected="emit('request-start-selected')"
@request-close="store.requestClose" @request-close="store.requestClose"
@toggle-select-all="store.toggleSelectAll"
@update:search-query="emit('update:search-query', $event)" @update:search-query="emit('update:search-query', $event)"
/> />
@@ -36,16 +42,13 @@
</p> </p>
</div> </div>
<div <div class="min-h-0 flex-1">
class="grid min-h-0 flex-1 gap-0 lg:grid-cols-[minmax(0,2fr)_minmax(320px,1fr)]"
>
<UpdateCenterList <UpdateCenterList
:items="store.filteredItems.value" :items="store.filteredItems.value"
:tasks="store.snapshot.value.tasks" :tasks="store.snapshot.value.tasks"
:selected-task-keys="store.selectedTaskKeys.value" :selected-task-keys="store.selectedTaskKeys.value"
@toggle-selection="emit('toggle-selection', $event)" @toggle-selection="emit('toggle-selection', $event)"
/> />
<UpdateCenterLogPanel :tasks="store.snapshot.value.tasks" />
</div> </div>
<UpdateCenterMigrationConfirm <UpdateCenterMigrationConfirm
@@ -53,11 +56,6 @@
@close="emit('dismiss-migration-confirm')" @close="emit('dismiss-migration-confirm')"
@confirm="emit('confirm-migration-start')" @confirm="emit('confirm-migration-start')"
/> />
<UpdateCenterCloseConfirm
:show="store.showCloseConfirm.value"
@close="emit('dismiss-close-confirm')"
@confirm="emit('confirm-close')"
/>
</div> </div>
</div> </div>
</Transition> </Transition>
@@ -68,9 +66,7 @@ import { computed } from "vue";
import type { UpdateCenterStore } from "@/modules/updateCenter"; import type { UpdateCenterStore } from "@/modules/updateCenter";
import UpdateCenterCloseConfirm from "./update-center/UpdateCenterCloseConfirm.vue";
import UpdateCenterList from "./update-center/UpdateCenterList.vue"; import UpdateCenterList from "./update-center/UpdateCenterList.vue";
import UpdateCenterLogPanel from "./update-center/UpdateCenterLogPanel.vue";
import UpdateCenterMigrationConfirm from "./update-center/UpdateCenterMigrationConfirm.vue"; import UpdateCenterMigrationConfirm from "./update-center/UpdateCenterMigrationConfirm.vue";
import UpdateCenterToolbar from "./update-center/UpdateCenterToolbar.vue"; import UpdateCenterToolbar from "./update-center/UpdateCenterToolbar.vue";
@@ -80,8 +76,6 @@ const emit = defineEmits<{
(e: "request-start-selected"): void; (e: "request-start-selected"): void;
(e: "confirm-migration-start"): void; (e: "confirm-migration-start"): void;
(e: "dismiss-migration-confirm"): void; (e: "dismiss-migration-confirm"): void;
(e: "confirm-close"): void;
(e: "dismiss-close-confirm"): void;
}>(); }>();
const props = defineProps<{ const props = defineProps<{
@@ -90,4 +84,14 @@ const props = defineProps<{
}>(); }>();
const selectedCount = computed(() => props.store.getSelectedItems().length); const selectedCount = computed(() => props.store.getSelectedItems().length);
const onOverlayWheel = (e: WheelEvent) => {
const target = e.target as HTMLElement;
if (target.closest(".overflow-y-auto, .overflow-auto")) return;
e.preventDefault();
};
const onOverlayClick = () => {
props.store.requestClose();
};
</script> </script>
@@ -1,6 +1,6 @@
<template> <template>
<div <div
class="min-h-0 overflow-y-auto border-r border-slate-200/70 p-6 dark:border-slate-800/70" class="min-h-0 overflow-y-auto overscroll-contain scrollbar-muted border-r border-slate-200/70 p-6 dark:border-slate-800/70"
> >
<div <div
v-if="items.length === 0" v-if="items.length === 0"
@@ -10,7 +10,9 @@
>{{ tasks.length }} </span >{{ tasks.length }} </span
> >
</div> </div>
<div class="mt-4 min-h-0 flex-1 space-y-4 overflow-y-auto"> <div
class="mt-4 min-h-0 flex-1 space-y-4 overflow-y-auto overscroll-contain"
>
<div <div
v-if="tasks.length === 0" v-if="tasks.length === 0"
class="rounded-2xl border border-dashed border-slate-200/80 px-4 py-8 text-center text-sm text-slate-500 dark:border-slate-800/80 dark:text-slate-400" class="rounded-2xl border border-dashed border-slate-200/80 px-4 py-8 text-center text-sm text-slate-500 dark:border-slate-800/80 dark:text-slate-400"
@@ -40,6 +40,22 @@
</div> </div>
</div> </div>
<div class="flex items-center gap-3">
<label class="inline-flex cursor-pointer items-center gap-2 select-none">
<input
ref="selectAllRef"
type="checkbox"
class="h-4 w-4 rounded border-slate-300 accent-brand focus:ring-brand"
:checked="allSelected"
@change="$emit('toggle-select-all')"
/>
<span class="text-sm font-medium text-slate-700 dark:text-slate-200">全选</span>
</label>
<span class="text-sm text-slate-400 dark:text-slate-500">
已选 {{ selectedCount }}
</span>
</div>
<label class="block"> <label class="block">
<span class="sr-only">搜索更新</span> <span class="sr-only">搜索更新</span>
<input <input
@@ -54,18 +70,35 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from "vue";
const props = defineProps<{ const props = defineProps<{
searchQuery: string; searchQuery: string;
selectedCount: number; selectedCount: number;
allSelected: boolean;
someSelected: boolean;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
(e: "refresh"): void; (e: "refresh"): void;
(e: "start-selected"): void; (e: "start-selected"): void;
(e: "request-close"): void; (e: "request-close"): void;
(e: "toggle-select-all"): void;
(e: "update:search-query", value: string): void; (e: "update:search-query", value: string): void;
}>(); }>();
const selectAllRef = ref<HTMLInputElement | null>(null);
watch(
[() => props.someSelected, () => props.allSelected],
() => {
if (selectAllRef.value) {
selectAllRef.value.indeterminate = props.someSelected && !props.allSelected;
}
},
{ flush: "post" },
);
const handleInput = (event: Event): void => { const handleInput = (event: Event): void => {
const target = event.target as HTMLInputElement | null; const target = event.target as HTMLInputElement | null;
emit("update:search-query", target?.value ?? props.searchQuery); emit("update:search-query", target?.value ?? props.searchQuery);
+28
View File
@@ -3,6 +3,34 @@ import type { DownloadItem, DownloadItemStatus } from "./typedefinition";
export const downloads = ref<DownloadItem[]>([]); export const downloads = ref<DownloadItem[]>([]);
let nextDownloadId = 1;
export function getNextDownloadId(): number {
if (downloads.value.length > 0) {
nextDownloadId = Math.max(
nextDownloadId,
Math.max(...downloads.value.map((item) => item.id)) + 1,
);
}
const downloadId = nextDownloadId;
nextDownloadId += 1;
return downloadId;
}
export function getNextUpdateDownloadId(): number {
const negativeIds = downloads.value
.map((item) => item.id)
.filter((id) => id < 0);
if (negativeIds.length === 0) {
return -1;
}
return Math.min(...negativeIds) - 1;
}
export function removeDownloadItem(pkgname: string) { export function removeDownloadItem(pkgname: string) {
const list = downloads.value; const list = downloads.value;
for (let i = list.length - 1; i >= 0; i -= 1) { for (let i = list.length - 1; i >= 0; i -= 1) {
+6 -1
View File
@@ -165,6 +165,11 @@ export interface UpdateCenterTaskState {
errorMessage: string; errorMessage: string;
} }
export interface UpdateCenterStartTask {
taskKey: string;
id: number;
}
export interface UpdateCenterSnapshot { export interface UpdateCenterSnapshot {
items: UpdateCenterItem[]; items: UpdateCenterItem[];
tasks: UpdateCenterTaskState[]; tasks: UpdateCenterTaskState[];
@@ -183,7 +188,7 @@ export interface UpdateCenterBridge {
packageName: string; packageName: string;
newVersion: string; newVersion: string;
}) => Promise<void>; }) => Promise<void>;
start: (taskKeys: string[]) => Promise<void>; start: (tasks: UpdateCenterStartTask[]) => Promise<void>;
cancel: (taskKey: string) => Promise<void>; cancel: (taskKey: string) => Promise<void>;
getState: () => Promise<UpdateCenterSnapshot>; getState: () => Promise<UpdateCenterSnapshot>;
onState: (listener: (snapshot: UpdateCenterSnapshot) => void) => void; onState: (listener: (snapshot: UpdateCenterSnapshot) => void) => void;
+72
View File
@@ -0,0 +1,72 @@
import type { App } from "@/global/typedefinition";
const normalizeSearchValue = (value: string | undefined): string =>
(value ?? "").toLowerCase().trim();
const getTieredMatchScore = (
value: string | undefined,
query: string,
exactScore: number,
prefixScore: number,
includesScore: number,
): number => {
const normalizedValue = normalizeSearchValue(value);
if (!normalizedValue || !query) return 0;
if (normalizedValue === query) return exactScore;
if (normalizedValue.startsWith(query)) return prefixScore;
if (normalizedValue.includes(query)) return includesScore;
return 0;
};
export const getSearchMatchScore = (app: App, query: string): number => {
const normalizedQuery = normalizeSearchValue(query);
if (!normalizedQuery) return 0;
return Math.max(
getTieredMatchScore(app.name, normalizedQuery, 400, 300, 200),
getTieredMatchScore(app.pkgname, normalizedQuery, 190, 180, 170),
getTieredMatchScore(app.tags, normalizedQuery, 160, 150, 140),
getTieredMatchScore(app.more, normalizedQuery, 130, 120, 110),
);
};
export const matchesSearch = (app: App, query: string): boolean =>
getSearchMatchScore(app, query) > 0;
export const rankAppsBySearch = (apps: App[], query: string): App[] =>
apps
.map((app, index) => ({
app,
index,
score: getSearchMatchScore(app, query),
}))
.filter((entry) => entry.score > 0)
.sort((left, right) => {
if (right.score !== left.score) {
return right.score - left.score;
}
return left.index - right.index;
})
.map((entry) => entry.app);
export const countSearchMatchesByCategory = (
apps: App[],
query: string,
): Record<string, number> => {
const counts: Record<string, number> = { all: 0 };
apps.forEach((app) => {
if (!matchesSearch(app, query)) {
return;
}
counts.all++;
if (!counts[app.category]) counts[app.category] = 0;
counts[app.category]++;
});
return counts;
};
+11 -6
View File
@@ -7,7 +7,7 @@ import {
currentAppApmInstalled, currentAppApmInstalled,
} from "../global/storeConfig"; } from "../global/storeConfig";
import { APM_STORE_BASE_URL } from "../global/storeConfig"; import { APM_STORE_BASE_URL } from "../global/storeConfig";
import { downloads } from "../global/downloadStatus"; import { downloads, getNextDownloadId } from "../global/downloadStatus";
import { import {
InstallLog, InstallLog,
@@ -18,7 +18,6 @@ import {
} from "../global/typedefinition"; } from "../global/typedefinition";
import axios from "axios"; import axios from "axios";
let downloadIdCounter = 0;
const logger = pino({ name: "processInstall.ts" }); const logger = pino({ name: "processInstall.ts" });
export const handleInstall = async (appObj?: App) => { export const handleInstall = async (appObj?: App) => {
@@ -51,14 +50,14 @@ export const handleInstall = async (appObj?: App) => {
return; return;
} }
downloadIdCounter += 1;
// 创建下载任务 // 创建下载任务
const arch = window.apm_store.arch || "amd64"; const arch = window.apm_store.arch || "amd64";
const finalArch = const finalArch =
targetApp.origin === "spark" ? `${arch}-store` : `${arch}-apm`; targetApp.origin === "spark" ? `${arch}-store` : `${arch}-apm`;
const downloadId = getNextDownloadId();
const download: DownloadItem = { const download: DownloadItem = {
id: downloadIdCounter, id: downloadId,
name: targetApp.name, name: targetApp.name,
pkgname: targetApp.pkgname, pkgname: targetApp.pkgname,
version: targetApp.version, version: targetApp.version,
@@ -140,12 +139,12 @@ export const handleUpgrade = async (app: App) => {
return; return;
} }
downloadIdCounter += 1;
const arch = window.apm_store.arch || "amd64"; const arch = window.apm_store.arch || "amd64";
const finalArch = app.origin === "spark" ? `${arch}-store` : `${arch}-apm`; const finalArch = app.origin === "spark" ? `${arch}-store` : `${arch}-apm`;
const downloadId = getNextDownloadId();
const download: DownloadItem = { const download: DownloadItem = {
id: downloadIdCounter, id: downloadId,
name: app.name, name: app.name,
pkgname: app.pkgname, pkgname: app.pkgname,
version: app.version, version: app.version,
@@ -235,3 +234,9 @@ window.ipcRenderer.on("install-complete", (_event, log: DownloadResult) => {
} }
} }
}); });
window.ipcRenderer.on("queue-install", (_event, payload: unknown) => {
const serializedPayload =
typeof payload === "string" ? payload : JSON.stringify(payload);
window.ipcRenderer.send("queue-install", serializedPayload);
});
+97 -9
View File
@@ -3,7 +3,11 @@ import { computed, ref, type ComputedRef, type Ref } from "vue";
import type { import type {
UpdateCenterItem, UpdateCenterItem,
UpdateCenterSnapshot, UpdateCenterSnapshot,
DownloadItem,
UpdateCenterStartTask,
} from "@/global/typedefinition"; } from "@/global/typedefinition";
import { downloads, getNextUpdateDownloadId } from "@/global/downloadStatus";
import { APM_STORE_BASE_URL } from "@/global/storeConfig";
const EMPTY_SNAPSHOT: UpdateCenterSnapshot = { const EMPTY_SNAPSHOT: UpdateCenterSnapshot = {
items: [], items: [],
@@ -20,11 +24,14 @@ export interface UpdateCenterStore {
selectedTaskKeys: Ref<Set<string>>; selectedTaskKeys: Ref<Set<string>>;
snapshot: Ref<UpdateCenterSnapshot>; snapshot: Ref<UpdateCenterSnapshot>;
filteredItems: ComputedRef<UpdateCenterItem[]>; filteredItems: ComputedRef<UpdateCenterItem[]>;
allSelected: ComputedRef<boolean>;
someSelected: ComputedRef<boolean>;
bind: () => void; bind: () => void;
unbind: () => void; unbind: () => void;
open: () => Promise<void>; open: () => Promise<void>;
refresh: () => Promise<void>; refresh: () => Promise<void>;
toggleSelection: (taskKey: string) => void; toggleSelection: (taskKey: string) => void;
toggleSelectAll: () => void;
getSelectedItems: () => UpdateCenterItem[]; getSelectedItems: () => UpdateCenterItem[];
closeNow: () => void; closeNow: () => void;
startSelected: () => Promise<void>; startSelected: () => Promise<void>;
@@ -71,11 +78,31 @@ export const createUpdateCenterStore = (): UpdateCenterStore => {
snapshot.value = nextSnapshot; snapshot.value = nextSnapshot;
}; };
const selectableItems = computed(() =>
snapshot.value.items.filter((item) => item.ignored !== true),
);
const filteredItems = computed(() => { const filteredItems = computed(() => {
const query = searchQuery.value.trim(); const query = searchQuery.value.trim();
return snapshot.value.items.filter((item) => matchesSearch(item, query)); return snapshot.value.items.filter((item) => matchesSearch(item, query));
}); });
const allSelected = computed(() => {
const selectable = selectableItems.value;
return (
selectable.length > 0 &&
selectable.every((item) => selectedTaskKeys.value.has(item.taskKey))
);
});
const someSelected = computed(() => {
const selectable = selectableItems.value;
return (
selectable.length > 0 &&
selectable.some((item) => selectedTaskKeys.value.has(item.taskKey))
);
});
const handleState = (nextSnapshot: UpdateCenterSnapshot): void => { const handleState = (nextSnapshot: UpdateCenterSnapshot): void => {
applySnapshot(nextSnapshot); applySnapshot(nextSnapshot);
}; };
@@ -130,6 +157,15 @@ export const createUpdateCenterStore = (): UpdateCenterStore => {
selectedTaskKeys.value = nextSelection; selectedTaskKeys.value = nextSelection;
}; };
const toggleSelectAll = (): void => {
const selectable = selectableItems.value;
if (allSelected.value) {
selectedTaskKeys.value = new Set();
} else {
selectedTaskKeys.value = new Set(selectable.map((item) => item.taskKey));
}
};
const getSelectedItems = (): UpdateCenterItem[] => { const getSelectedItems = (): UpdateCenterItem[] => {
return snapshot.value.items.filter( return snapshot.value.items.filter(
(item) => (item) =>
@@ -143,21 +179,70 @@ export const createUpdateCenterStore = (): UpdateCenterStore => {
}; };
const startSelected = async (): Promise<void> => { const startSelected = async (): Promise<void> => {
const taskKeys = getSelectedItems().map((item) => item.taskKey); const selectedItems = getSelectedItems();
if (selectedItems.length === 0) {
if (taskKeys.length === 0) {
return; return;
} }
await window.updateCenter.start(taskKeys); // 在前端创建下载项,这样用户能在下载列表中看到更新任务
const arch = window.apm_store.arch || "amd64";
const startTasks: UpdateCenterStartTask[] = [];
selectedItems.forEach((item) => {
// 检查任务是否已存在
if (
!downloads.value.find(
(d) =>
d.pkgname === item.packageName &&
d.origin === (item.source === "apm" ? "apm" : "spark"),
)
) {
const finalArch =
item.source === "apm" ? `${arch}-apm` : `${arch}-store`;
const icon =
item.remoteIcon ||
`${APM_STORE_BASE_URL}/${finalArch}/unknown/${item.packageName}/icon.png`;
const downloadId = getNextUpdateDownloadId();
const download: DownloadItem = {
id: downloadId,
name: item.displayName,
pkgname: item.packageName,
version: item.newVersion,
icon,
origin: item.source === "apm" ? "apm" : "spark",
status: "queued",
progress: 0,
downloadedSize: 0,
totalSize: item.size || 0,
speed: 0,
timeRemaining: 0,
startTime: Date.now(),
logs: [{ time: Date.now(), message: "开始更新..." }],
source: "Update Center",
retry: false,
upgradeOnly: true,
filename: item.fileName,
metalinkUrl: item.downloadUrl
? `${item.downloadUrl}.metalink`
: undefined,
};
downloads.value.push(download);
startTasks.push({
taskKey: item.taskKey,
id: downloadId,
});
}
});
if (startTasks.length === 0) {
return;
}
await window.updateCenter.start(startTasks);
}; };
const requestClose = (): void => { const requestClose = (): void => {
if (snapshot.value.hasRunningTasks) { // 直接关闭,不需要确认,因为任务在主下载队列中执行
showCloseConfirm.value = true;
return;
}
closeNow(); closeNow();
}; };
@@ -169,11 +254,14 @@ export const createUpdateCenterStore = (): UpdateCenterStore => {
selectedTaskKeys, selectedTaskKeys,
snapshot, snapshot,
filteredItems, filteredItems,
allSelected,
someSelected,
bind, bind,
unbind, unbind,
open, open,
refresh, refresh,
toggleSelection, toggleSelection,
toggleSelectAll,
getSelectedItems, getSelectedItems,
closeNow, closeNow,
startSelected, startSelected,
+1 -1
View File
@@ -61,7 +61,7 @@ function launch_app() {
# 提取并净化Exec命令 # 提取并净化Exec命令
exec_command=$(grep -m1 '^Exec=' "$DESKTOP_FILE_PATH" | cut -d= -f2- | sed 's/%.//g') exec_command=$(grep -m1 '^Exec=' "$DESKTOP_FILE_PATH" | cut -d= -f2- | sed 's/%.//g')
[ -z "$exec_command" ] && return 1 [ -z "$exec_command" ] && return 1
[ ! -z "$IS_ACE_ENV" ] && HOST_PREFIX="host-spawn" [ ! -z "$IS_ACE_ENV" ] && HOST_PREFIX="systemd-run --user"
exec_command="${HOST_PREFIX} $exec_command" exec_command="${HOST_PREFIX} $exec_command"
log.info "Launching: $exec_command" log.info "Launching: $exec_command"
${SHELL:-bash} -c " $exec_command" & ${SHELL:-bash} -c " $exec_command" &
+6
View File
@@ -4,6 +4,7 @@ import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
import electron from 'vite-plugin-electron/simple' import electron from 'vite-plugin-electron/simple'
import pkg from './package.json' import pkg from './package.json'
import { resolve } from 'path'
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig(({ command }) => { export default defineConfig(({ command }) => {
@@ -110,5 +111,10 @@ export default defineConfig(({ command }) => {
define: { define: {
__APP_VERSION__: JSON.stringify(process.env.npm_package_version), __APP_VERSION__: JSON.stringify(process.env.npm_package_version),
}, },
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
}
}; };
}); });