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