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