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