Compare commits

..

1 Commits

Author SHA1 Message Date
momen 4f6d223da4 fix: 搜索聚焦和初始化时平滑滚动到顶部
在搜索框获得焦点和应用初始化时,除了切换分类外,添加平滑滚动到顶部的功能,提升用户体验
2026-04-11 12:43:40 +08:00
44 changed files with 2931 additions and 3605 deletions
+1 -2
View File
@@ -38,5 +38,4 @@ pnpm-lock.yaml
yarn.lock
.lock
test-results.json
.worktrees/
test-results.json
+1 -1
View File
@@ -12,7 +12,7 @@
### 系统要求
- **Node.js:** >= 22.12.0
- **Node.js:** >= 20.x
- **npm:** >= 9.x 或 pnpm >= 8.x
- **操作系统:** Linux(推荐 Ubuntu 22.04+
- **可选:** APM 包管理器(用于测试)
@@ -1,801 +0,0 @@
# 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.
@@ -1,229 +0,0 @@
# 更新中心图标逐级回退设计
## 背景
当前更新中心的图标解析分成两段:
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. 本地图标成功时,不再加载线上图标和默认图标。
@@ -1,196 +0,0 @@
# 更新中心 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"
linux:
icon: "icons/spark-store.png"
icon: "icons/amber-pm-logo.icns"
category: "System"
executableName: "spark-store"
desktop:
+19 -18
View File
@@ -148,7 +148,8 @@ ipcMain.on("queue-install", async (event, download_json) => {
typeof download_json === "string"
? JSON.parse(download_json)
: download_json;
const { id, pkgname, metalinkUrl, filename, origin } = download || {};
const { id, pkgname, metalinkUrl, filename, upgradeOnly, origin } =
download || {};
if (!id || !pkgname) {
logger.warn("passed arguments missing id or pkgname");
@@ -248,24 +249,24 @@ ipcMain.on("queue-install", async (event, download_json) => {
}
if (origin === "spark") {
execCommand = superUserCmd || SHELL_CALLER_PATH;
if (superUserCmd) execParams.push(SHELL_CALLER_PATH);
if (metalinkUrl && filename) {
execParams.push(
"ssinstall",
`${downloadDir}/${filename}`,
"--delete-after-install",
"--no-create-desktop-entry",
"--native",
);
// Spark Store logic
if (upgradeOnly) {
execCommand = superUserCmd || SHELL_CALLER_PATH;
if (superUserCmd) execParams.push(SHELL_CALLER_PATH);
execParams.push("aptss", "install", "-y", pkgname, "--only-upgrade");
} else {
execParams.push(
"ssinstall",
pkgname,
"--no-create-desktop-entry",
"--native",
);
execCommand = superUserCmd || SHELL_CALLER_PATH;
if (superUserCmd) execParams.push(SHELL_CALLER_PATH);
if (metalinkUrl && filename) {
execParams.push(
"ssinstall",
`${downloadDir}/${filename}`,
"--delete-after-install",
);
} else {
execParams.push("aptss", "install", "-y", pkgname);
}
}
} else {
// APM Store logic
-368
View File
@@ -1,368 +0,0 @@
/**
* 共享的安装/下载逻辑
* 被 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("");
});
});
};
+58 -13
View File
@@ -1,5 +1,7 @@
import { mkdir } from "node:fs/promises";
import { join } from "node:path";
import { downloadPackage, type DownloadResult } from "../shared-installer";
import { spawn } from "node:child_process";
import type { UpdateCenterItem } from "./types";
export interface Aria2DownloadResult {
@@ -14,6 +16,8 @@ export interface RunAria2DownloadOptions {
signal?: AbortSignal;
}
const PROGRESS_PATTERN = /(\d{1,3}(?:\.\d+)?)%/;
export const runAria2Download = async ({
item,
downloadDir,
@@ -25,18 +29,59 @@ export const runAria2Download = async ({
throw new Error(`Missing download metadata for ${item.pkgname}`);
}
// 使用与商店安装相同的下载逻辑
const metalinkUrl = `${item.downloadUrl}.metalink`;
const result = await downloadPackage({
pkgname: item.pkgname,
metalinkUrl,
filename: item.fileName,
downloadDir,
onLog,
onProgress,
signal,
await mkdir(downloadDir, { recursive: true });
const filePath = join(downloadDir, item.fileName);
await new Promise<void>((resolve, reject) => {
const child = spawn("aria2c", [
"--dir",
downloadDir,
"--out",
item.fileName,
item.downloadUrl,
]);
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}`));
});
});
return { filePath: result.filePath };
onProgress?.(100);
return { filePath };
};
+22 -114
View File
@@ -14,7 +14,6 @@ import {
createUpdateCenterService,
type UpdateCenterIgnorePayload,
type UpdateCenterService,
type UpdateCenterStartTask,
} from "./service";
import type { UpdateCenterItem } from "./types";
@@ -34,20 +33,14 @@ export interface UpdateCenterLoadItemsResult {
warnings: string[];
}
interface RemoteAppMetadata {
category: string;
name?: string;
}
type StoreAppMetadataMap = Map<string, RemoteAppMetadata>;
type StoreCategoryMap = Map<string, string>;
interface RemoteCategoryAppEntry {
Name?: string;
Pkgname?: string;
}
const REMOTE_STORE_BASE_URL = "https://erotica.spark-app.store";
const categoryCache = new Map<string, Promise<StoreAppMetadataMap>>();
const categoryCache = new Map<string, Promise<StoreCategoryMap>>();
const APTSS_LIST_UPGRADABLE_COMMAND = {
command: "bash",
@@ -73,14 +66,6 @@ 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 (
command,
args,
@@ -155,58 +140,6 @@ 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 (
items: UpdateCenterItem[],
runCommand: UpdateCenterCommandRunner,
@@ -223,22 +156,6 @@ 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 = (
item: Pick<UpdateCenterItem, "source" | "arch">,
): string => {
@@ -265,7 +182,7 @@ const loadJson = async <T>(url: string): Promise<T> => {
const loadStoreCategoryMap = async (
storeArch: string,
): Promise<StoreAppMetadataMap> => {
): Promise<StoreCategoryMap> => {
const categories = await loadJson<Record<string, unknown>>(
`${REMOTE_STORE_BASE_URL}/${storeArch}/categories.json`,
);
@@ -279,7 +196,7 @@ const loadStoreCategoryMap = async (
}),
);
const categoryMap: StoreAppMetadataMap = new Map();
const categoryMap: StoreCategoryMap = new Map();
for (const entry of categoryEntries) {
if (entry.status !== "fulfilled") {
continue;
@@ -287,10 +204,7 @@ const loadStoreCategoryMap = async (
for (const app of entry.value.apps) {
if (app.Pkgname && !categoryMap.has(app.Pkgname)) {
categoryMap.set(app.Pkgname, {
category: entry.value.category,
name: app.Name,
});
categoryMap.set(app.Pkgname, entry.value.category);
}
}
}
@@ -298,9 +212,7 @@ const loadStoreCategoryMap = async (
return categoryMap;
};
const getStoreCategoryMap = (
storeArch: string,
): Promise<StoreAppMetadataMap> => {
const getStoreCategoryMap = (storeArch: string): Promise<StoreCategoryMap> => {
const cached = categoryCache.get(storeArch);
if (cached) {
return cached;
@@ -329,14 +241,8 @@ const enrichItemCategories = async (
}
const categoryMap = await getStoreCategoryMap(storeArch);
const metadata = categoryMap.get(item.pkgname);
return metadata
? {
...item,
category: metadata.category,
...(metadata.name ? { name: metadata.name } : {}),
}
: item;
const category = categoryMap.get(item.pkgname);
return category ? { ...item, category } : item;
}),
);
};
@@ -393,22 +299,18 @@ export const loadUpdateCenterItems = async (
enrichItemCategories(aptssItems),
enrichItemCategories(apmItems),
]);
const [enrichedAptssItems, enrichedApmItems] = await Promise.all([
enrichAptssItems(categorizedAptssItems, runCommand),
enrichApmItems(categorizedApmItems, runCommand),
]);
const enrichedApmItems = await enrichApmItems(
categorizedApmItems,
runCommand,
);
return {
items: mergeUpdateSources(
enrichItemIcons(enrichedAptssItems.items),
enrichItemIcons(categorizedAptssItems),
enrichItemIcons(enrichedApmItems.items),
installedSources,
),
warnings: [
...warnings,
...enrichedAptssItems.warnings,
...enrichedApmItems.warnings,
],
warnings: [...warnings, ...enrichedApmItems.warnings],
};
};
@@ -436,8 +338,8 @@ export const registerUpdateCenterIpc = (
"update-center-unignore",
(_event, payload: UpdateCenterIgnorePayload) => service.unignore(payload),
);
ipc.handle("update-center-start", (_event, tasks: UpdateCenterStartTask[]) =>
service.start(tasks),
ipc.handle("update-center-start", (_event, taskKeys: string[]) =>
service.start(taskKeys),
);
ipc.handle("update-center-cancel", (_event, taskKey: string) =>
service.cancel(taskKey),
@@ -458,8 +360,14 @@ export const initializeUpdateCenter = (): UpdateCenterService => {
return updateCenterService;
}
const superUserCmdProvider = async (): Promise<string> => {
const installManager = await import("../install-manager.js");
return installManager.checkSuperUserCommand();
};
updateCenterService = createUpdateCenterService({
loadItems: loadUpdateCenterItems,
superUserCmdProvider,
});
registerUpdateCenterIpc(ipcMain, updateCenterService);
+158 -35
View File
@@ -1,12 +1,19 @@
import { spawn } from "node:child_process";
import { join } from "node:path";
import { runAria2Download, type Aria2DownloadResult } from "./download";
import { installPackage } from "../shared-installer";
import type { UpdateCenterQueue, UpdateCenterTask } from "./queue";
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";
export interface UpdateCommand {
execCommand: string;
execParams: string[];
}
export interface InstallUpdateItemOptions {
item: UpdateCenterItem;
filePath?: string;
@@ -48,10 +55,94 @@ export interface CreateTaskRunnerOptions extends TaskRunnerDependencies {
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 ({
item,
filePath,
@@ -59,23 +150,45 @@ export const installUpdateItem = async ({
onLog,
signal,
}: InstallUpdateItemOptions): Promise<void> => {
if (!filePath) {
throw new Error(
`Update task for ${item.pkgname} requires downloaded package file`,
);
if (item.source === "apm" && !filePath) {
throw new Error("APM update task requires downloaded package metadata");
}
// 使用与商店安装相同的安装逻辑
const origin = item.source === "apm" ? "apm" : "spark";
await installPackage({
pkgname: item.pkgname,
filePath,
origin,
superUserCmd,
onLog,
signal,
});
if (item.source === "apm" && filePath) {
const installCommand = buildPrivilegedCommand(
SHELL_CALLER_PATH,
["apm", "ssinstall", filePath],
superUserCmd,
);
await runCommand(
installCommand.execCommand,
installCommand.execParams,
onLog,
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 = (
@@ -108,7 +221,11 @@ export const createTaskRunner = (
return {
cancelActiveTask: () => {
activeAbortController?.abort();
if (!activeAbortController || activeAbortController.signal.aborted) {
return;
}
activeAbortController.abort();
},
runNextTask: async () => {
if (inFlightTask) {
@@ -129,24 +246,30 @@ export const createTaskRunner = (
};
try {
// All updates require download metadata
if (!task.item.downloadUrl || !task.item.fileName) {
let filePath: string | undefined;
if (
task.item.source === "apm" &&
(!task.item.downloadUrl || !task.item.fileName)
) {
throw new Error(
`Update task for ${task.item.pkgname} requires download metadata (URL and filename)`,
"APM update task requires downloaded package metadata",
);
}
queue.markActiveTask(task.id, "downloading");
const result = await runDownload({
item: task.item,
task,
onLog,
signal: activeAbortController.signal,
onProgress: (progress) => {
queue.updateTaskProgress(task.id, progress);
},
});
const filePath = result.filePath;
if (task.item.downloadUrl && task.item.fileName) {
queue.markActiveTask(task.id, "downloading");
const result = await runDownload({
item: task.item,
task,
onLog,
signal: activeAbortController.signal,
onProgress: (progress) => {
queue.updateTaskProgress(task.id, progress);
},
});
filePath = result.filePath;
}
queue.markActiveTask(task.id, "installing");
await installItem({
+1 -3
View File
@@ -270,9 +270,7 @@ export const parsePrintUrisOutput = (
return null;
}
const [, rawDownloadUrl, fileName, size, sha512] = match;
// Clean up the URL: remove backticks and extra spaces
const downloadUrl = rawDownloadUrl.replace(/[`'"]/g, "").trim();
const [, downloadUrl, fileName, size, sha512] = match;
return {
downloadUrl,
fileName,
+18 -37
View File
@@ -88,8 +88,8 @@ export const createUpdateCenterQueue = (): UpdateCenterQueue => {
let refreshing = false;
let nextTaskId = 1;
const getTaskIndex = (taskId: number): number =>
tasks.findIndex((task) => task.id === taskId);
const getTask = (taskId: number): UpdateCenterTask | undefined =>
tasks.find((task) => task.id === taskId);
return {
setItems: (nextItems) => {
@@ -117,59 +117,40 @@ export const createUpdateCenterQueue = (): UpdateCenterQueue => {
return task;
},
markActiveTask: (taskId, status) => {
const taskIndex = getTaskIndex(taskId);
if (taskIndex === -1) {
const task = getTask(taskId);
if (!task) {
return;
}
// 创建新的 task 对象和新的 tasks 数组以触发状态更新
tasks = tasks.map((task, index) =>
index === taskIndex ? { ...task, status } : task,
);
task.status = status;
},
updateTaskProgress: (taskId, progress) => {
const taskIndex = getTaskIndex(taskId);
if (taskIndex === -1) {
const task = getTask(taskId);
if (!task) {
return;
}
// 创建新的 task 对象和新的 tasks 数组以触发状态更新
tasks = tasks.map((task, index) =>
index === taskIndex
? { ...task, progress: clampProgress(progress) }
: task,
);
task.progress = clampProgress(progress);
},
appendTaskLog: (taskId, message, time = Date.now()) => {
const taskIndex = getTaskIndex(taskId);
if (taskIndex === -1) {
const task = getTask(taskId);
if (!task) {
return;
}
// 创建新的 task 对象和新的 tasks 数组以触发状态更新
tasks = tasks.map((task, index) =>
index === taskIndex
? { ...task, logs: [...task.logs, { time, message }] }
: task,
);
task.logs = [...task.logs, { time, message }];
},
finishTask: (taskId, status, error) => {
const taskIndex = getTaskIndex(taskId);
if (taskIndex === -1) {
const task = getTask(taskId);
if (!task) {
return;
}
// 创建新的 task 对象和新的 tasks 数组以触发状态更新
tasks = tasks.map((task, index) =>
index === taskIndex
? {
...task,
status,
error,
progress: status === "completed" ? 100 : task.progress,
}
: task,
);
task.status = status;
task.error = error;
if (status === "completed") {
task.progress = 100;
}
},
getNextQueuedTask: () => tasks.find((task) => task.status === "queued"),
getSnapshot: () => createSnapshot(items, tasks, warnings, refreshing),
+98 -60
View File
@@ -1,4 +1,3 @@
import { BrowserWindow } from "electron";
import {
LEGACY_IGNORE_CONFIG_PATH,
applyIgnoredEntries,
@@ -6,8 +5,10 @@ import {
loadIgnoredEntries,
saveIgnoredEntries,
} from "./ignore-config";
import { createTaskRunner, type UpdateCenterTaskRunner } from "./install";
import {
createUpdateCenterQueue,
type UpdateCenterQueue,
type UpdateCenterQueueSnapshot,
} from "./queue";
import type { UpdateCenterItem, UpdateSource } from "./types";
@@ -61,17 +62,12 @@ export interface UpdateCenterIgnorePayload {
newVersion: string;
}
export interface UpdateCenterStartTask {
taskKey: string;
id: number;
}
export interface UpdateCenterService {
open: () => Promise<UpdateCenterServiceState>;
refresh: () => Promise<UpdateCenterServiceState>;
ignore: (payload: UpdateCenterIgnorePayload) => Promise<void>;
unignore: (payload: UpdateCenterIgnorePayload) => Promise<void>;
start: (tasks: UpdateCenterStartTask[]) => Promise<void>;
start: (taskKeys: string[]) => Promise<void>;
cancel: (taskKey: string) => Promise<void>;
getState: () => UpdateCenterServiceState;
subscribe: (
@@ -83,6 +79,11 @@ export interface CreateUpdateCenterServiceOptions {
loadItems: () => Promise<UpdateCenterItem[] | UpdateCenterLoadedItems>;
loadIgnoredEntries?: () => Promise<Set<string>>;
saveIgnoredEntries?: (entries: ReadonlySet<string>) => Promise<void>;
createTaskRunner?: (
queue: UpdateCenterQueue,
superUserCmd?: string,
) => UpdateCenterTaskRunner;
superUserCmdProvider?: () => Promise<string>;
}
const getTaskKey = (
@@ -95,7 +96,7 @@ const toState = (
items: snapshot.items.map((item) => ({
taskKey: getTaskKey(item),
packageName: item.pkgname,
displayName: item.name || item.pkgname,
displayName: item.pkgname,
currentVersion: item.currentVersion,
newVersion: item.nextVersion,
source: item.source,
@@ -111,9 +112,19 @@ const toState = (
migrationTarget: item.migrationTarget,
aptssVersion: item.aptssVersion,
})),
tasks: [], // 不再展示任务日志
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: false, // 任务不在更新中心执行
hasRunningTasks: snapshot.hasRunningTasks,
});
const normalizeLoadedItems = (
@@ -141,6 +152,12 @@ export const createUpdateCenterService = (
options.saveIgnoredEntries ??
((entries: ReadonlySet<string>) =>
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 => {
queue.finishRefresh([message]);
@@ -150,9 +167,9 @@ export const createUpdateCenterService = (
const emit = (): UpdateCenterServiceState => {
const snapshot = getState();
listeners.forEach((listener) => {
for (const listener of listeners) {
listener(snapshot);
});
}
return snapshot;
};
@@ -175,6 +192,47 @@ 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 {
open: refresh,
refresh,
@@ -190,69 +248,49 @@ export const createUpdateCenterService = (
await saveIgnored(entries);
await refresh();
},
async start(tasks) {
async start(taskKeys) {
const snapshot = queue.getSnapshot();
const taskIdByKey = new Map(tasks.map((task) => [task.taskKey, task.id]));
const existingTaskKeys = new Set(
snapshot.tasks
.filter(
(task) =>
!["completed", "failed", "cancelled"].includes(task.status),
)
.map((task) => getTaskKey(task.item)),
);
const selectedItems = snapshot.items.filter(
(item) => taskIdByKey.has(getTaskKey(item)) && !item.ignored,
(item) =>
taskKeys.includes(getTaskKey(item)) &&
!item.ignored &&
!existingTaskKeys.has(getTaskKey(item)),
);
if (selectedItems.length === 0) {
return;
}
// 获取主窗口的 webContents
const mainWindow = BrowserWindow.getAllWindows()[0];
const webContents = mainWindow?.webContents;
for (const item of selectedItems) {
queue.enqueueItem(item);
}
emit();
if (!webContents) {
console.error("No main window found");
await ensureProcessing();
},
async cancel(taskKey) {
const task = queue
.getSnapshot()
.tasks.find((entry) => getTaskKey(entry.item) === taskKey);
if (!task) {
return;
}
// 获取当前 items
let currentItems = snapshot.items;
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),
);
queue.finishTask(task.id, "cancelled", "Cancelled");
if (["downloading", "installing"].includes(task.status)) {
activeRunner?.cancelActiveTask();
}
// 更新队列中的 items
queue.setItems(currentItems);
emit();
},
async cancel(taskKey) {
// 取消功能不再需要通过更新中心,直接忽略
console.log("Cancel not needed for task:", taskKey);
},
getState,
subscribe(listener) {
listeners.add(listener);
@@ -7,7 +7,6 @@ export interface InstalledSourceState {
export interface UpdateCenterItem {
pkgname: string;
name?: string;
source: UpdateSource;
currentVersion: 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 scriptPath =
"/opt/durapps/spark-store/bin/update-upgrade/ss-update-controler.sh";
const child = spawn("systemd-run", ["--user", scriptPath], {
const child = spawn("/opt/spark-store/extras/host-spawn", [scriptPath], {
detached: true,
stdio: "ignore",
});
+2 -7
View File
@@ -33,11 +33,6 @@ type UpdateCenterSnapshot = {
hasRunningTasks: boolean;
};
type UpdateCenterStartTask = {
taskKey: string;
id: number;
};
type IpcRendererFacade = {
on: typeof ipcRenderer.on;
off: typeof ipcRenderer.off;
@@ -103,8 +98,8 @@ contextBridge.exposeInMainWorld("updateCenter", {
packageName: string;
newVersion: string;
}): Promise<void> => ipcRenderer.invoke("update-center-unignore", payload),
start: (tasks: UpdateCenterStartTask[]): Promise<void> =>
ipcRenderer.invoke("update-center-start", tasks),
start: (taskKeys: string[]): Promise<void> =>
ipcRenderer.invoke("update-center-start", taskKeys),
cancel: (taskKey: string): Promise<void> =>
ipcRenderer.invoke("update-center-cancel", taskKey),
getState: (): Promise<UpdateCenterSnapshot> =>
+1 -1
View File
@@ -61,7 +61,7 @@ function launch_app() {
# 提取并净化Exec命令
exec_command=$(grep -m1 '^Exec=' "$DESKTOP_FILE_PATH" | cut -d= -f2- | sed 's/%.//g')
[ -z "$exec_command" ] && return 1
[ ! -z "$IS_ACE_ENV" ] && HOST_PREFIX="systemd-run --user"
[ ! -z "$IS_ACE_ENV" ] && HOST_PREFIX="host-spawn"
exec_command="${HOST_PREFIX} $exec_command"
log.info "Launching: $exec_command"
${SHELL:-bash} -c " $exec_command" &
+2446 -959
View File
File diff suppressed because it is too large Load Diff
+4 -8
View File
@@ -1,14 +1,11 @@
{
"name": "spark-store",
"version": "5.0.0beta4",
"version": "5.0.0beta3",
"main": "dist-electron/main/index.js",
"description": "Client for Spark App Store",
"author": "elysia-best <elysia-best@simplelinux.cn.eu.org>",
"license": "GPL-3.0",
"private": true,
"engines": {
"node": ">=22.12.0"
},
"keywords": [
"electron",
"rollup",
@@ -17,7 +14,6 @@
"vue",
"spark-app-store"
],
"homepage": "https://spark-app.store",
"debug": {
"env": {
"VITE_DEV_SERVER_URL": "http://127.0.0.1:3344/"
@@ -47,12 +43,12 @@
"@dotenvx/dotenvx": "^1.51.4",
"@eslint/create-config": "^1.11.0",
"@eslint/js": "^9.39.2",
"@loongdotjs/electron-builder": "^26.8.2-loong1",
"@loongdotjs/electron-builder": "^26.0.12-1",
"@playwright/test": "^1.40.0",
"@testing-library/jest-dom": "^6.1.5",
"@testing-library/vue": "^8.0.1",
"@vitejs/plugin-vue": "^6.0.3",
"@vitest/coverage-v8": "^4.1.4",
"@vitest/coverage-v8": "^1.0.0",
"@vue/test-utils": "^2.4.3",
"conventional-changelog": "^7.1.1",
"conventional-changelog-angular": "^8.1.0",
@@ -71,7 +67,7 @@
"vite": "^6.4.1",
"vite-plugin-electron": "^0.29.0",
"vite-plugin-electron-renderer": "^0.14.5",
"vitest": "^4.1.4",
"vitest": "^1.0.0",
"vue": "^3.4.21",
"vue-tsc": "^3.2.4"
},
+37 -14
View File
@@ -182,10 +182,6 @@ import {
removeDownloadItem,
watchDownloadsChange,
} from "./global/downloadStatus";
import {
countSearchMatchesByCategory,
rankAppsBySearch,
} from "./modules/appSearch";
import { handleInstall, handleRetry } from "./modules/processInstall";
import { createUpdateCenterStore } from "./modules/updateCenter";
import type {
@@ -264,7 +260,7 @@ const apmAvailable = ref(false);
const storeFilter = ref<"spark" | "apm" | "both">("both");
// 计算属性
const baseApps = computed(() => {
const filteredApps = computed(() => {
let result = [...apps.value];
// 合并相同包名的应用 (混合模式)
@@ -288,27 +284,50 @@ const baseApps = computed(() => {
result = Array.from(mergedMap.values());
}
return result;
});
const filteredApps = computed(() => {
let result = [...baseApps.value];
// 按分类筛选
if (activeCategory.value !== "all") {
result = result.filter((app) => app.category === activeCategory.value);
}
// 按搜索关键词筛选
if (searchQuery.value.trim()) {
return rankAppsBySearch(result, searchQuery.value);
const q = searchQuery.value.toLowerCase().trim();
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;
});
const categoryCounts = computed(() => {
// 如果有搜索关键词,显示搜索结果数量
if (searchQuery.value.trim()) {
return countSearchMatchesByCategory(baseApps.value, searchQuery.value);
const q = searchQuery.value.toLowerCase().trim();
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;
}
// 无搜索时显示总数量
@@ -1125,7 +1144,10 @@ const handleSearchInput = (value: string) => {
};
const handleSearchFocus = () => {
if (activeCategory.value === "home") activeCategory.value = "all";
if (activeCategory.value === "home") {
activeCategory.value = "all";
window.scrollTo({ top: 0, behavior: "smooth" });
}
};
// 生命周期钩子
@@ -1233,6 +1255,7 @@ onMounted(async () => {
const tryOpen = () => {
// 先切换到"全部应用"分类
activeCategory.value = "all";
window.scrollTo({ top: 0, behavior: "smooth" });
// 使用类似 HomeView 的方式打开应用,从两个仓库获取完整信息
const target = apps.value.find((a) => a.pkgname === data.pkgname);
if (target) {
@@ -1,25 +0,0 @@
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
@@ -1,148 +0,0 @@
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
@@ -1,36 +0,0 @@
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,12 +19,6 @@ const DPKG_QUERY_INSTALLED_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";
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 (
remoteStore: Record<string, RemoteStoreResponse>,
) => {
@@ -147,28 +141,19 @@ describe("update-center load items", () => {
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({
"https://erotica.spark-app.store/amd64-store/categories.json": {
tools: { zh: "Tools" },
},
"https://erotica.spark-app.store/amd64-store/tools/applist.json": [
{ Name: "Spark Weather", Pkgname: "spark-weather" },
{ Pkgname: "spark-weather" },
],
"https://erotica.spark-app.store/amd64-apm/categories.json": {
tools: { zh: "Tools" },
},
"https://erotica.spark-app.store/amd64-apm/tools/applist.json": [
{ Name: "Spark Weather", Pkgname: "spark-weather" },
{ Pkgname: "spark-weather" },
],
});
@@ -190,7 +175,6 @@ describe("update-center load items", () => {
nextVersion: "3.0.0",
arch: "amd64",
category: "tools",
name: "Spark Weather",
remoteIcon:
"https://erotica.spark-app.store/amd64-apm/tools/spark-weather/icon.png",
downloadUrl: "https://example.invalid/spark-weather_3.0.0_amd64.deb",
@@ -210,7 +194,7 @@ describe("update-center load items", () => {
office: { zh: "Office" },
},
"https://erotica.spark-app.store/amd64-store/office/applist.json": [
{ Name: "Spark Notes", Pkgname: "spark-notes" },
{ Pkgname: "spark-notes" },
],
});
@@ -233,15 +217,6 @@ 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") {
return {
code: 127,
@@ -261,13 +236,8 @@ describe("update-center load items", () => {
nextVersion: "2.0.0",
arch: "amd64",
category: "office",
name: "Spark Notes",
remoteIcon:
"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([
@@ -319,7 +289,6 @@ describe("update-center load items", () => {
currentVersion: "1.0.0",
nextVersion: "2.0.0",
arch: "amd64",
name: "Spark Notes",
},
]);
@@ -329,7 +298,7 @@ describe("update-center load items", () => {
};
remoteStore[
"https://erotica.spark-app.store/amd64-store/office/applist.json"
] = [{ Name: "Spark Notes", Pkgname: "spark-notes" }];
] = [{ Pkgname: "spark-notes" }];
const secondResult = await loadUpdateCenterItems(runCommand);
@@ -341,7 +310,6 @@ describe("update-center load items", () => {
nextVersion: "2.0.0",
arch: "amd64",
category: "office",
name: "Spark Notes",
remoteIcon:
"https://erotica.spark-app.store/amd64-store/office/spark-notes/icon.png",
},
@@ -355,7 +323,7 @@ describe("update-center load items", () => {
tools: { zh: "Tools" },
},
"https://erotica.spark-app.store/amd64-store/office/applist.json": [
{ Name: "Spark Notes", Pkgname: "spark-notes" },
{ Pkgname: "spark-notes" },
],
});
@@ -378,15 +346,6 @@ 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") {
return {
code: 127,
@@ -406,7 +365,6 @@ describe("update-center load items", () => {
nextVersion: "2.0.0",
arch: "amd64",
category: "office",
name: "Spark Notes",
remoteIcon:
"https://erotica.spark-app.store/amd64-store/office/spark-notes/icon.png",
},
@@ -262,27 +262,6 @@ describe("update-center/ipc", () => {
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 () => {
const startedTaskIds: number[] = [];
const releases: Array<() => void> = [];
@@ -1,53 +0,0 @@
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,
}),
);
});
});
+1 -75
View File
@@ -1,7 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createUpdateCenterStore } from "@/modules/updateCenter";
import { downloads } from "@/global/downloadStatus";
const createSnapshot = (overrides = {}) => ({
items: [
@@ -34,7 +33,6 @@ describe("updateCenter store", () => {
start.mockReset();
onState.mockReset();
offState.mockReset();
downloads.value = [];
Object.defineProperty(window, "updateCenter", {
configurable: true,
@@ -96,79 +94,7 @@ describe("updateCenter store", () => {
store.toggleSelection("apm:spark-clock");
await store.startSelected();
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,
},
]);
expect(start).toHaveBeenCalledWith(["aptss:spark-weather"]);
});
it("blocks close requests while the snapshot reports running tasks", () => {
@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest";
import type { UpdateCenterItem } from "../../../../electron/main/backend/update-center/types";
import {
createTaskRunner,
buildLegacySparkUpgradeCommand,
installUpdateItem,
} from "../../../../electron/main/backend/update-center/install";
import { createUpdateCenterQueue } from "../../../../electron/main/backend/update-center/queue";
@@ -113,6 +114,22 @@ 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", () => {
const queue = createUpdateCenterQueue();
const item = createAptssItem();
@@ -191,7 +208,7 @@ describe("update-center task runner", () => {
{
id: task.id,
status: "failed",
error: "Update task for spark-player requires download metadata (URL and filename)",
error: "APM update task requires downloaded package metadata",
},
],
});
@@ -283,28 +300,4 @@ 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",
],
},
]);
});
});
+3 -10
View File
@@ -12,10 +12,9 @@
v-bind="attrs"
class="fixed inset-0 z-50 flex items-center justify-center overflow-hidden bg-slate-900/70 p-4"
@click.self="closeModal"
@wheel="onOverlayWheel"
>
<div
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"
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"
>
<!-- 返回按钮 - sticky定位在模态框内部左上角滚动时始终可见 -->
<button
@@ -277,7 +276,7 @@
应用详情
</h3>
<div
class="max-h-48 overflow-y-auto overscroll-contain text-sm leading-relaxed text-slate-600 dark:text-slate-300 space-y-2"
class="max-h-48 overflow-y-auto text-sm leading-relaxed text-slate-600 dark:text-slate-300 space-y-2"
v-html="displayApp.more.replace(/\n/g, '<br>')"
></div>
</div>
@@ -352,7 +351,7 @@
应用信息
</h3>
<div
class="max-h-80 overflow-y-auto overscroll-contain rounded-xl bg-slate-50 p-4 dark:bg-slate-900/50 space-y-3"
class="max-h-80 overflow-y-auto rounded-xl bg-slate-50 p-4 dark:bg-slate-900/50 space-y-3"
>
<div v-if="displayApp?.name" class="flex justify-between">
<span class="text-sm text-slate-500">应用名称</span>
@@ -630,10 +629,4 @@ const openPreview = (index: number) => {
const hideImage = (e: Event) => {
(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>
+2 -9
View File
@@ -11,10 +11,9 @@
v-if="show"
class="fixed inset-0 z-50 flex items-start justify-center bg-slate-900/70 px-4 py-10"
@click="handleOverlayClick"
@wheel="onOverlayWheel"
>
<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 overscroll-contain 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 rounded-3xl border border-white/10 bg-white/95 p-6 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
@click.stop
>
<div class="flex items-start justify-between">
@@ -155,7 +154,7 @@
</button>
</div>
<div
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"
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"
>
<div
v-for="(log, index) in download.logs"
@@ -311,10 +310,4 @@ const copyLogs = () => {
const downloadProgress = computed(() => {
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>
+1 -4
View File
@@ -49,10 +49,7 @@
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 -translate-y-2"
>
<div
v-show="isExpanded"
class="max-h-96 overflow-y-auto overscroll-contain px-3 pb-4"
>
<div v-show="isExpanded" class="max-h-96 overflow-y-auto px-3 pb-4">
<div
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"
+1 -8
View File
@@ -11,7 +11,6 @@
v-if="show"
class="fixed inset-0 z-50 flex items-start justify-center bg-slate-900/70 px-4 py-10"
@click.self="$emit('close')"
@wheel="onOverlayWheel"
>
<div
class="flex w-full max-w-4xl max-h-[85vh] flex-col rounded-3xl border border-white/10 bg-white/95 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
@@ -79,7 +78,7 @@
</div>
<div
class="flex-1 overflow-y-auto overscroll-contain scrollbar-nowidth scrollbar-thumb-slate-200 dark:scrollbar-thumb-slate-700 p-6 space-y-4"
class="flex-1 overflow-y-auto scrollbar-nowidth scrollbar-thumb-slate-200 dark:scrollbar-thumb-slate-700 p-6 space-y-4"
>
<div
v-if="loading"
@@ -194,10 +193,4 @@ defineEmits<{
(e: "uninstall", app: App): 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>
+13 -17
View File
@@ -10,22 +10,16 @@
<div
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"
@wheel="onOverlayWheel"
@click="onOverlayClick"
>
<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"
@click.stop
>
<UpdateCenterToolbar
:search-query="store.searchQuery.value"
:selected-count="selectedCount"
:all-selected="store.allSelected.value"
:some-selected="store.someSelected.value"
@refresh="store.refresh"
@start-selected="emit('request-start-selected')"
@request-close="store.requestClose"
@toggle-select-all="store.toggleSelectAll"
@update:search-query="emit('update:search-query', $event)"
/>
@@ -42,13 +36,16 @@
</p>
</div>
<div class="min-h-0 flex-1">
<div
class="grid min-h-0 flex-1 gap-0 lg:grid-cols-[minmax(0,2fr)_minmax(320px,1fr)]"
>
<UpdateCenterList
:items="store.filteredItems.value"
:tasks="store.snapshot.value.tasks"
:selected-task-keys="store.selectedTaskKeys.value"
@toggle-selection="emit('toggle-selection', $event)"
/>
<UpdateCenterLogPanel :tasks="store.snapshot.value.tasks" />
</div>
<UpdateCenterMigrationConfirm
@@ -56,6 +53,11 @@
@close="emit('dismiss-migration-confirm')"
@confirm="emit('confirm-migration-start')"
/>
<UpdateCenterCloseConfirm
:show="store.showCloseConfirm.value"
@close="emit('dismiss-close-confirm')"
@confirm="emit('confirm-close')"
/>
</div>
</div>
</Transition>
@@ -66,7 +68,9 @@ import { computed } from "vue";
import type { UpdateCenterStore } from "@/modules/updateCenter";
import UpdateCenterCloseConfirm from "./update-center/UpdateCenterCloseConfirm.vue";
import UpdateCenterList from "./update-center/UpdateCenterList.vue";
import UpdateCenterLogPanel from "./update-center/UpdateCenterLogPanel.vue";
import UpdateCenterMigrationConfirm from "./update-center/UpdateCenterMigrationConfirm.vue";
import UpdateCenterToolbar from "./update-center/UpdateCenterToolbar.vue";
@@ -76,6 +80,8 @@ const emit = defineEmits<{
(e: "request-start-selected"): void;
(e: "confirm-migration-start"): void;
(e: "dismiss-migration-confirm"): void;
(e: "confirm-close"): void;
(e: "dismiss-close-confirm"): void;
}>();
const props = defineProps<{
@@ -84,14 +90,4 @@ const props = defineProps<{
}>();
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>
@@ -1,6 +1,6 @@
<template>
<div
class="min-h-0 overflow-y-auto overscroll-contain scrollbar-muted border-r border-slate-200/70 p-6 dark:border-slate-800/70"
class="min-h-0 overflow-y-auto border-r border-slate-200/70 p-6 dark:border-slate-800/70"
>
<div
v-if="items.length === 0"
@@ -10,9 +10,7 @@
>{{ tasks.length }} </span
>
</div>
<div
class="mt-4 min-h-0 flex-1 space-y-4 overflow-y-auto overscroll-contain"
>
<div class="mt-4 min-h-0 flex-1 space-y-4 overflow-y-auto">
<div
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"
@@ -40,22 +40,6 @@
</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">
<span class="sr-only">搜索更新</span>
<input
@@ -70,35 +54,18 @@
</template>
<script setup lang="ts">
import { ref, watch } from "vue";
const props = defineProps<{
searchQuery: string;
selectedCount: number;
allSelected: boolean;
someSelected: boolean;
}>();
const emit = defineEmits<{
(e: "refresh"): void;
(e: "start-selected"): void;
(e: "request-close"): void;
(e: "toggle-select-all"): 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 target = event.target as HTMLInputElement | null;
emit("update:search-query", target?.value ?? props.searchQuery);
-28
View File
@@ -3,34 +3,6 @@ import type { DownloadItem, DownloadItemStatus } from "./typedefinition";
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) {
const list = downloads.value;
for (let i = list.length - 1; i >= 0; i -= 1) {
+1 -6
View File
@@ -165,11 +165,6 @@ export interface UpdateCenterTaskState {
errorMessage: string;
}
export interface UpdateCenterStartTask {
taskKey: string;
id: number;
}
export interface UpdateCenterSnapshot {
items: UpdateCenterItem[];
tasks: UpdateCenterTaskState[];
@@ -188,7 +183,7 @@ export interface UpdateCenterBridge {
packageName: string;
newVersion: string;
}) => Promise<void>;
start: (tasks: UpdateCenterStartTask[]) => Promise<void>;
start: (taskKeys: string[]) => Promise<void>;
cancel: (taskKey: string) => Promise<void>;
getState: () => Promise<UpdateCenterSnapshot>;
onState: (listener: (snapshot: UpdateCenterSnapshot) => void) => void;
-72
View File
@@ -1,72 +0,0 @@
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;
};
+6 -11
View File
@@ -7,7 +7,7 @@ import {
currentAppApmInstalled,
} from "../global/storeConfig";
import { APM_STORE_BASE_URL } from "../global/storeConfig";
import { downloads, getNextDownloadId } from "../global/downloadStatus";
import { downloads } from "../global/downloadStatus";
import {
InstallLog,
@@ -18,6 +18,7 @@ import {
} from "../global/typedefinition";
import axios from "axios";
let downloadIdCounter = 0;
const logger = pino({ name: "processInstall.ts" });
export const handleInstall = async (appObj?: App) => {
@@ -50,14 +51,14 @@ export const handleInstall = async (appObj?: App) => {
return;
}
downloadIdCounter += 1;
// 创建下载任务
const arch = window.apm_store.arch || "amd64";
const finalArch =
targetApp.origin === "spark" ? `${arch}-store` : `${arch}-apm`;
const downloadId = getNextDownloadId();
const download: DownloadItem = {
id: downloadId,
id: downloadIdCounter,
name: targetApp.name,
pkgname: targetApp.pkgname,
version: targetApp.version,
@@ -139,12 +140,12 @@ export const handleUpgrade = async (app: App) => {
return;
}
downloadIdCounter += 1;
const arch = window.apm_store.arch || "amd64";
const finalArch = app.origin === "spark" ? `${arch}-store` : `${arch}-apm`;
const downloadId = getNextDownloadId();
const download: DownloadItem = {
id: downloadId,
id: downloadIdCounter,
name: app.name,
pkgname: app.pkgname,
version: app.version,
@@ -234,9 +235,3 @@ 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);
});
+9 -97
View File
@@ -3,11 +3,7 @@ import { computed, ref, type ComputedRef, type Ref } from "vue";
import type {
UpdateCenterItem,
UpdateCenterSnapshot,
DownloadItem,
UpdateCenterStartTask,
} from "@/global/typedefinition";
import { downloads, getNextUpdateDownloadId } from "@/global/downloadStatus";
import { APM_STORE_BASE_URL } from "@/global/storeConfig";
const EMPTY_SNAPSHOT: UpdateCenterSnapshot = {
items: [],
@@ -24,14 +20,11 @@ export interface UpdateCenterStore {
selectedTaskKeys: Ref<Set<string>>;
snapshot: Ref<UpdateCenterSnapshot>;
filteredItems: ComputedRef<UpdateCenterItem[]>;
allSelected: ComputedRef<boolean>;
someSelected: ComputedRef<boolean>;
bind: () => void;
unbind: () => void;
open: () => Promise<void>;
refresh: () => Promise<void>;
toggleSelection: (taskKey: string) => void;
toggleSelectAll: () => void;
getSelectedItems: () => UpdateCenterItem[];
closeNow: () => void;
startSelected: () => Promise<void>;
@@ -78,31 +71,11 @@ export const createUpdateCenterStore = (): UpdateCenterStore => {
snapshot.value = nextSnapshot;
};
const selectableItems = computed(() =>
snapshot.value.items.filter((item) => item.ignored !== true),
);
const filteredItems = computed(() => {
const query = searchQuery.value.trim();
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 => {
applySnapshot(nextSnapshot);
};
@@ -157,15 +130,6 @@ export const createUpdateCenterStore = (): UpdateCenterStore => {
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[] => {
return snapshot.value.items.filter(
(item) =>
@@ -179,70 +143,21 @@ export const createUpdateCenterStore = (): UpdateCenterStore => {
};
const startSelected = async (): Promise<void> => {
const selectedItems = getSelectedItems();
if (selectedItems.length === 0) {
const taskKeys = getSelectedItems().map((item) => item.taskKey);
if (taskKeys.length === 0) {
return;
}
// 在前端创建下载项,这样用户能在下载列表中看到更新任务
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);
await window.updateCenter.start(taskKeys);
};
const requestClose = (): void => {
// 直接关闭,不需要确认,因为任务在主下载队列中执行
if (snapshot.value.hasRunningTasks) {
showCloseConfirm.value = true;
return;
}
closeNow();
};
@@ -254,14 +169,11 @@ export const createUpdateCenterStore = (): UpdateCenterStore => {
selectedTaskKeys,
snapshot,
filteredItems,
allSelected,
someSelected,
bind,
unbind,
open,
refresh,
toggleSelection,
toggleSelectAll,
getSelectedItems,
closeNow,
startSelected,
+1 -1
View File
@@ -61,7 +61,7 @@ function launch_app() {
# 提取并净化Exec命令
exec_command=$(grep -m1 '^Exec=' "$DESKTOP_FILE_PATH" | cut -d= -f2- | sed 's/%.//g')
[ -z "$exec_command" ] && return 1
[ ! -z "$IS_ACE_ENV" ] && HOST_PREFIX="systemd-run --user"
[ ! -z "$IS_ACE_ENV" ] && HOST_PREFIX="host-spawn"
exec_command="${HOST_PREFIX} $exec_command"
log.info "Launching: $exec_command"
${SHELL:-bash} -c " $exec_command" &
-6
View File
@@ -4,7 +4,6 @@ import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
import electron from 'vite-plugin-electron/simple'
import pkg from './package.json'
import { resolve } from 'path'
// https://vitejs.dev/config/
export default defineConfig(({ command }) => {
@@ -111,10 +110,5 @@ export default defineConfig(({ command }) => {
define: {
__APP_VERSION__: JSON.stringify(process.env.npm_package_version),
},
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
}
};
});