feat(update-center): 实现集中式软件更新中心功能

新增更新中心模块,支持管理 APM 和传统 deb 软件更新任务
- 添加更新任务队列管理、状态跟踪和日志记录功能
- 实现更新项忽略配置持久化存储
- 新增更新确认对话框和迁移提示
- 优化主窗口关闭时的任务保护机制
- 添加单元测试覆盖核心逻辑
This commit is contained in:
2026-04-09 08:19:51 +08:00
parent 97bb8e5f59
commit 0b17ada45a
37 changed files with 6389 additions and 342 deletions

View File

@@ -126,17 +126,18 @@
@switch-origin="handleSwitchOrigin"
/>
<UpdateAppsModal
:show="showUpdateModal"
:apps="upgradableApps"
:loading="updateLoading"
:error="updateError"
:has-selected="hasSelectedUpgrades"
@close="closeUpdateModal"
@refresh="refreshUpgradableApps"
@toggle-all="toggleAllUpgrades"
@upgrade-selected="upgradeSelectedApps"
@upgrade-one="upgradeSingleApp"
<UpdateCenterModal
:show="updateCenterStore.isOpen.value"
:store="updateCenterStore"
@update:search-query="updateCenterStore.searchQuery.value = $event"
@toggle-selection="updateCenterStore.toggleSelection"
@request-start-selected="handleStartSelectedUpdates"
@confirm-migration-start="confirmMigrationStart"
@dismiss-migration-confirm="
updateCenterStore.showMigrationConfirm.value = false
"
@confirm-close="updateCenterStore.closeNow()"
@dismiss-close-confirm="updateCenterStore.showCloseConfirm.value = false"
/>
<UninstallConfirmModal
@@ -151,7 +152,7 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch, nextTick } from "vue";
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from "vue";
import axios from "axios";
import pino from "pino";
import AppSidebar from "./components/AppSidebar.vue";
@@ -163,7 +164,7 @@ import ScreenPreview from "./components/ScreenPreview.vue";
import DownloadQueue from "./components/DownloadQueue.vue";
import DownloadDetail from "./components/DownloadDetail.vue";
import InstalledAppsModal from "./components/InstalledAppsModal.vue";
import UpdateAppsModal from "./components/UpdateAppsModal.vue";
import UpdateCenterModal from "./components/UpdateCenterModal.vue";
import UninstallConfirmModal from "./components/UninstallConfirmModal.vue";
import AboutModal from "./components/AboutModal.vue";
import {
@@ -180,20 +181,17 @@ import {
removeDownloadItem,
watchDownloadsChange,
} from "./global/downloadStatus";
import {
handleInstall,
handleRetry,
handleUpgrade,
} from "./modules/processInstall";
import { handleInstall, handleRetry } from "./modules/processInstall";
import { createUpdateCenterStore } from "./modules/updateCenter";
import type {
App,
AppJson,
DownloadItem,
UpdateAppItem,
ChannelPayload,
CategoryInfo,
HomeLink,
HomeList,
UpdateCenterItem,
} from "./global/typedefinition";
import type { Ref } from "vue";
import type { IpcRendererEvent } from "electron";
@@ -251,12 +249,7 @@ const activeInstalledOrigin = ref<"apm" | "spark">("apm");
const installedApps = ref<App[]>([]);
const installedLoading = ref(false);
const installedError = ref("");
const showUpdateModal = ref(false);
const upgradableApps = ref<(App & { selected: boolean; upgrading: boolean })[]>(
[],
);
const updateLoading = ref(false);
const updateError = ref("");
const updateCenterStore = createUpdateCenterStore();
const showUninstallModal = ref(false);
const uninstallTargetApp: Ref<App | null> = ref(null);
const showAboutModal = ref(false);
@@ -345,10 +338,6 @@ const categoryCounts = computed(() => {
return counts;
});
const hasSelectedUpgrades = computed(() => {
return upgradableApps.value.some((app) => app.selected);
});
// 方法
const syncThemePreference = () => {
document.documentElement.classList.toggle("dark", isDarkTheme.value);
@@ -568,7 +557,9 @@ const openDetail = async (app: App | Record<string, unknown>) => {
finalApp.viewingOrigin = "apm";
} else {
// 若都安装或都未安装,根据优先级配置决定默认展示
finalApp.viewingOrigin = getHybridDefaultOrigin(finalApp.sparkApp || finalApp);
finalApp.viewingOrigin = getHybridDefaultOrigin(
finalApp.sparkApp || finalApp,
);
}
}
@@ -769,14 +760,7 @@ const nextScreen = () => {
};
const handleUpdate = async () => {
try {
const result = await window.ipcRenderer.invoke("run-update-tool");
if (!result || !result.success) {
logger.warn(`启动更新工具失败: ${result?.message || "未知错误"}`);
}
} catch (error) {
logger.error(`调用更新工具时出错: ${error}`);
}
await openUpdateModal();
};
const handleOpenInstallSettings = async () => {
@@ -794,94 +778,35 @@ const handleList = () => {
openInstalledModal();
};
const openUpdateModal = () => {
showUpdateModal.value = true;
refreshUpgradableApps();
};
const closeUpdateModal = () => {
showUpdateModal.value = false;
};
const refreshUpgradableApps = async () => {
updateLoading.value = true;
updateError.value = "";
const openUpdateModal = async () => {
try {
const result = await window.ipcRenderer.invoke("list-upgradable");
if (!result?.success) {
upgradableApps.value = [];
updateError.value = result?.message || "检查更新失败";
return;
}
upgradableApps.value = (result.apps || []).map(
(app: Record<string, string>) => ({
...app,
// Map properties if needed or assume main matches App interface except field names might differ
// For now assuming result.apps returns objects compatible with App for core fields,
// but let's normalize just in case if main returns different structure.
name: app.name || app.Name || "",
pkgname: app.pkgname || app.Pkgname || "",
version: app.newVersion || app.version || "",
category: app.category || "unknown",
selected: false,
upgrading: false,
}),
);
} catch (error: unknown) {
upgradableApps.value = [];
updateError.value = (error as Error)?.message || "检查更新失败";
} finally {
updateLoading.value = false;
await updateCenterStore.open();
} catch (error) {
logger.error(`打开更新中心失败: ${error}`);
}
};
const toggleAllUpgrades = () => {
const shouldSelectAll =
!hasSelectedUpgrades.value ||
upgradableApps.value.some((app) => !app.selected);
upgradableApps.value = upgradableApps.value.map((app) => ({
...app,
selected: shouldSelectAll ? true : false,
}));
const hasMigrationSelection = (items: UpdateCenterItem[]): boolean => {
return items.some((item) => item.isMigration === true);
};
const upgradeSingleApp = async (app: UpdateAppItem) => {
if (!app?.pkgname) return;
const target = apps.value.find((a) => a.pkgname === app.pkgname);
if (target) {
await handleUpgrade(target);
} else {
// If we can't find it in the list (e.g. category not loaded?), use the info we have
// But handleUpgrade expects App. Let's try to construct minimal App
let minimalApp: App = {
name: app.pkgname,
pkgname: app.pkgname,
version: app.newVersion || "",
category: "unknown",
tags: "",
more: "",
filename: "",
torrent_address: "",
author: "",
contributor: "",
website: "",
update: "",
size: "",
img_urls: [],
icons: "",
origin: "apm", // Default to APM if unknown, or try to guess
currentStatus: "installed",
};
await handleUpgrade(minimalApp);
const handleStartSelectedUpdates = async () => {
const selectedItems = updateCenterStore.getSelectedItems();
if (selectedItems.length === 0) {
return;
}
if (hasMigrationSelection(selectedItems)) {
updateCenterStore.showMigrationConfirm.value = true;
return;
}
await updateCenterStore.startSelected();
};
const upgradeSelectedApps = async () => {
const selectedApps = upgradableApps.value.filter((app) => app.selected);
for (const app of selectedApps) {
await upgradeSingleApp(app);
}
const confirmMigrationStart = async () => {
updateCenterStore.showMigrationConfirm.value = false;
await updateCenterStore.startSelected();
};
const openInstalledModal = () => {
@@ -1224,6 +1149,7 @@ const handleSearchFocus = () => {
// 生命周期钩子
onMounted(async () => {
initTheme();
updateCenterStore.bind();
// 从主进程获取启动参数(--no-apm / --no-spark再加载数据
storeFilter.value = await window.ipcRenderer.invoke("get-store-filter");
@@ -1365,6 +1291,10 @@ onMounted(async () => {
logger.info("Renderer process is ready!");
});
onUnmounted(() => {
updateCenterStore.unbind();
});
// 观察器
watch(themeMode, (newVal) => {
localStorage.setItem("theme", newVal);

View File

@@ -0,0 +1,182 @@
import { computed, ref } from "vue";
import { fireEvent, render, screen } from "@testing-library/vue";
import { describe, expect, it, vi } from "vitest";
import UpdateCenterModal from "@/components/UpdateCenterModal.vue";
import type {
UpdateCenterItem,
UpdateCenterSnapshot,
UpdateCenterTaskState,
} from "@/global/typedefinition";
import type { UpdateCenterStore } from "@/modules/updateCenter";
const createItem = (
overrides: Partial<UpdateCenterItem> = {},
): UpdateCenterItem => ({
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,
});
const createStore = (
overrides: Partial<UpdateCenterSnapshot> = {},
): UpdateCenterStore => {
const snapshot = ref<UpdateCenterSnapshot>({
items: [
createItem({
taskKey: "aptss:spark-weather",
source: "aptss",
}),
createItem({
taskKey: "apm:spark-clock",
packageName: "spark-clock",
displayName: "Spark Clock",
source: "apm",
isMigration: true,
migrationTarget: "apm",
}),
],
tasks: [createTask()],
warnings: ["更新过程中请勿关闭商店"],
hasRunningTasks: true,
...overrides,
});
const selectedTaskKeys = ref(new Set<string>(["aptss:spark-weather"]));
return {
isOpen: ref(true),
showCloseConfirm: ref(true),
showMigrationConfirm: ref(false),
searchQuery: ref(""),
selectedTaskKeys,
snapshot,
filteredItems: computed(() => snapshot.value.items),
bind: vi.fn(),
unbind: vi.fn(),
open: vi.fn(),
refresh: vi.fn(),
toggleSelection: vi.fn(),
getSelectedItems: vi.fn(() =>
snapshot.value.items.filter(
(item) =>
selectedTaskKeys.value.has(item.taskKey) && item.ignored !== true,
),
),
closeNow: vi.fn(),
startSelected: vi.fn(),
requestClose: vi.fn(),
};
};
describe("UpdateCenterModal", () => {
it("renders source tags, running state, warnings, migration marker, and close confirmation", () => {
const store = createStore();
render(UpdateCenterModal, {
props: {
show: true,
store,
},
});
expect(screen.getByText("软件更新")).toBeTruthy();
expect(screen.getByText("传统deb")).toBeTruthy();
expect(screen.getByText("APM")).toBeTruthy();
expect(screen.getByText("将迁移到 APM")).toBeTruthy();
expect(screen.getByText("更新过程中请勿关闭商店")).toBeTruthy();
expect(screen.getByText("下载中")).toBeTruthy();
expect(screen.getByText("42%")).toBeTruthy();
expect(screen.getByText(/确定关闭/)).toBeTruthy();
});
it("close confirmation exposes a confirm-close path", async () => {
const onConfirmClose = vi.fn();
const store = createStore();
render(UpdateCenterModal, {
props: {
show: true,
store,
onConfirmClose,
},
});
await fireEvent.click(screen.getByRole("button", { name: "确认关闭" }));
expect(onConfirmClose).toHaveBeenCalledTimes(1);
});
it("renders ignored items as disabled instead of normal selectable actions", () => {
const store = createStore({
items: [
createItem({
taskKey: "aptss:spark-weather",
packageName: "spark-weather",
displayName: "Spark Weather",
source: "aptss",
ignored: true,
}),
],
tasks: [],
warnings: [],
hasRunningTasks: false,
});
render(UpdateCenterModal, {
props: {
show: true,
store,
},
});
expect(screen.getByText("已忽略")).toBeTruthy();
expect(screen.getByRole("checkbox")).toBeDisabled();
});
it("renders migration confirmation when requested", () => {
const store = createStore({ hasRunningTasks: false });
store.showMigrationConfirm.value = true;
render(UpdateCenterModal, {
props: {
show: true,
store,
},
});
expect(screen.getByText("迁移确认")).toBeTruthy();
});
it("close button triggers request-close flow", async () => {
const store = createStore({ hasRunningTasks: false });
render(UpdateCenterModal, {
props: {
show: true,
store,
},
});
await fireEvent.click(screen.getByRole("button", { name: "关闭" }));
expect(store.requestClose).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,124 @@
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { describe, expect, it } from "vitest";
import type { UpdateCenterItem } from "../../../../electron/main/backend/update-center/types";
import {
LEGACY_IGNORE_CONFIG_PATH,
applyIgnoredEntries,
createIgnoreKey,
loadIgnoredEntries,
parseIgnoredEntries,
saveIgnoredEntries,
} from "../../../../electron/main/backend/update-center/ignore-config";
describe("update-center ignore config", () => {
it("round-trips the legacy package|version format", async () => {
expect(LEGACY_IGNORE_CONFIG_PATH).toBe(
"/etc/spark-store/ignored_apps.conf",
);
const entries = new Set([
createIgnoreKey("spark-clock", "2.0.0"),
createIgnoreKey("spark-browser", "1.5.0"),
]);
const tempDir = await mkdtemp(join(tmpdir(), "spark-ignore-config-"));
const filePath = join(tempDir, "ignored_apps.conf");
try {
await saveIgnoredEntries(filePath, entries);
expect(await readFile(filePath, "utf8")).toBe(
"spark-browser|1.5.0\nspark-clock|2.0.0\n",
);
expect(
parseIgnoredEntries("spark-browser|1.5.0\nspark-clock|2.0.0\n"),
).toEqual(new Set(["spark-browser|1.5.0", "spark-clock|2.0.0"]));
await expect(loadIgnoredEntries(filePath)).resolves.toEqual(entries);
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
it("ignores malformed lines and accepts CRLF legacy entries", () => {
expect(
parseIgnoredEntries(
[
"spark-browser|1.5.0\r",
"spark-clock|2.0.0|extra",
"missing-version|",
"|missing-package",
"spark-player|3.0.0",
"",
].join("\n"),
),
).toEqual(new Set(["spark-browser|1.5.0", "spark-player|3.0.0"]));
});
it("marks only exact package/version matches as ignored", () => {
const items: UpdateCenterItem[] = [
{
pkgname: "spark-clock",
source: "aptss",
currentVersion: "1.0.0",
nextVersion: "2.0.0",
},
{
pkgname: "spark-clock",
source: "apm",
currentVersion: "1.1.0",
nextVersion: "2.1.0",
},
{
pkgname: "spark-browser",
source: "apm",
currentVersion: "4.0.0",
nextVersion: "5.0.0",
},
];
expect(
applyIgnoredEntries(
items,
new Set([createIgnoreKey("spark-clock", "2.0.0")]),
),
).toEqual([
{
pkgname: "spark-clock",
source: "aptss",
currentVersion: "1.0.0",
nextVersion: "2.0.0",
ignored: true,
},
{
pkgname: "spark-clock",
source: "apm",
currentVersion: "1.1.0",
nextVersion: "2.1.0",
ignored: false,
},
{
pkgname: "spark-browser",
source: "apm",
currentVersion: "4.0.0",
nextVersion: "5.0.0",
ignored: false,
},
]);
});
it("missing file returns an empty set", async () => {
const tempDir = await mkdtemp(
join(tmpdir(), "spark-ignore-config-missing-"),
);
const filePath = join(tempDir, "missing.conf");
try {
await expect(loadIgnoredEntries(filePath)).resolves.toEqual(new Set());
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
});

View File

@@ -0,0 +1,134 @@
import { describe, expect, it } from "vitest";
import { loadUpdateCenterItems } from "../../../../electron/main/backend/update-center";
interface CommandResult {
code: number;
stdout: string;
stderr: string;
}
const APTSS_LIST_UPGRADABLE_KEY =
"bash -lc env LANGUAGE=en_US /usr/bin/apt -c /opt/durapps/spark-store/bin/apt-fast-conf/aptss-apt.conf list --upgradable -o Dir::Etc::sourcelist=/opt/durapps/spark-store/bin/apt-fast-conf/sources.list.d/aptss.list -o Dir::Etc::sourceparts=/dev/null -o APT::Get::List-Cleanup=0";
const DPKG_QUERY_INSTALLED_KEY =
"dpkg-query -W -f=${Package}\t${db:Status-Want} ${db:Status-Status} ${db:Status-Eflag}\n";
describe("update-center load items", () => {
it("enriches apm and migration items with download metadata needed by the runner", async () => {
const commandResults = new Map<string, CommandResult>([
[
APTSS_LIST_UPGRADABLE_KEY,
{
code: 0,
stdout: "spark-weather/stable 2.0.0 amd64 [upgradable from: 1.0.0]",
stderr: "",
},
],
[
"apm list --upgradable",
{
code: 0,
stdout: "spark-weather/main 3.0.0 amd64 [upgradable from: 1.5.0]",
stderr: "",
},
],
[
DPKG_QUERY_INSTALLED_KEY,
{
code: 0,
stdout: "spark-weather\tinstall ok installed\n",
stderr: "",
},
],
[
"apm list --installed",
{
code: 0,
stdout: "",
stderr: "",
},
],
[
"apm info spark-weather --print-uris",
{
code: 0,
stdout:
"'https://example.invalid/spark-weather_3.0.0_amd64.deb' spark-weather_3.0.0_amd64.deb 123456 SHA512:deadbeef",
stderr: "",
},
],
]);
const result = await loadUpdateCenterItems(async (command, args) => {
const key = `${command} ${args.join(" ")}`;
const match = commandResults.get(key);
if (!match) {
throw new Error(`Missing mock for ${key}`);
}
return match;
});
expect(result.warnings).toEqual([]);
expect(result.items).toContainEqual({
pkgname: "spark-weather",
source: "apm",
currentVersion: "1.5.0",
nextVersion: "3.0.0",
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",
});
});
it("degrades to aptss-only results when apm commands fail", async () => {
const result = await loadUpdateCenterItems(async (command, args) => {
const key = `${command} ${args.join(" ")}`;
if (key === APTSS_LIST_UPGRADABLE_KEY) {
return {
code: 0,
stdout: "spark-notes/stable 2.0.0 amd64 [upgradable from: 1.0.0]",
stderr: "",
};
}
if (key === DPKG_QUERY_INSTALLED_KEY) {
return {
code: 0,
stdout: "spark-notes\tinstall ok installed\n",
stderr: "",
};
}
if (key === "apm list --upgradable" || key === "apm list --installed") {
return {
code: 127,
stdout: "",
stderr: "apm: command not found",
};
}
throw new Error(`Unexpected command ${key}`);
});
expect(result.items).toEqual([
{
pkgname: "spark-notes",
source: "aptss",
currentVersion: "1.0.0",
nextVersion: "2.0.0",
},
]);
expect(result.warnings).toEqual([
"apm upgradable query failed: apm: command not found",
"apm installed query failed: apm: command not found",
]);
});
});

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import {
getMainWindowCloseAction,
shouldPreventMainWindowClose,
} from "../../../../electron/main/window-close-guard";
describe("main window close guard", () => {
it("keeps the app alive while update-center work is running", () => {
expect(
shouldPreventMainWindowClose({
installTaskCount: 0,
hasRunningUpdateCenterTasks: true,
}),
).toBe(true);
});
it("allows close only when both install and update-center work are idle", () => {
expect(
shouldPreventMainWindowClose({
installTaskCount: 0,
hasRunningUpdateCenterTasks: false,
}),
).toBe(false);
});
it("returns a hide action while any guarded work is active", () => {
expect(
getMainWindowCloseAction({
installTaskCount: 1,
hasRunningUpdateCenterTasks: false,
}),
).toBe("hide");
});
it("returns a destroy action only when all guarded work is idle", () => {
expect(
getMainWindowCloseAction({
installTaskCount: 0,
hasRunningUpdateCenterTasks: false,
}),
).toBe("destroy");
});
});

View File

@@ -0,0 +1,294 @@
import { describe, expect, it, vi } from "vitest";
import {
buildInstalledSourceMap,
mergeUpdateSources,
parseApmUpgradableOutput,
parseAptssUpgradableOutput,
parsePrintUrisOutput,
} from "../../../../electron/main/backend/update-center/query";
describe("update-center query", () => {
it("parses aptss upgradable output into normalized aptss items", () => {
const output = [
"Listing...",
"spark-weather/stable 2.0.0 amd64 [upgradable from: 1.9.0]",
"spark-same/stable 1.0.0 amd64 [upgradable from: 1.0.0]",
"",
].join("\n");
expect(parseAptssUpgradableOutput(output)).toEqual([
{
pkgname: "spark-weather",
source: "aptss",
currentVersion: "1.9.0",
nextVersion: "2.0.0",
},
]);
});
it("parses the legacy from variant in upgradable output", () => {
const aptssOutput = "spark-clock/stable 1.2.0 amd64 [from: 1.1.0]";
const apmOutput = "spark-player/main 2.0.0 amd64 [from: 1.5.0]";
expect(parseAptssUpgradableOutput(aptssOutput)).toEqual([
{
pkgname: "spark-clock",
source: "aptss",
currentVersion: "1.1.0",
nextVersion: "1.2.0",
},
]);
expect(parseApmUpgradableOutput(apmOutput)).toEqual([
{
pkgname: "spark-player",
source: "apm",
currentVersion: "1.5.0",
nextVersion: "2.0.0",
},
]);
});
it("parses apt print-uris output into download metadata", () => {
const output =
"'https://example.invalid/pool/main/s/spark-weather_2.0.0_amd64.deb' spark-weather_2.0.0_amd64.deb 123456 SHA512:deadbeef";
expect(parsePrintUrisOutput(output)).toEqual({
downloadUrl:
"https://example.invalid/pool/main/s/spark-weather_2.0.0_amd64.deb",
fileName: "spark-weather_2.0.0_amd64.deb",
size: 123456,
sha512: "deadbeef",
});
});
it("marks an apm item as migration when the same package is only installed in aptss", () => {
const merged = mergeUpdateSources(
[
{
pkgname: "spark-weather",
source: "aptss",
currentVersion: "1.9.0",
nextVersion: "2.0.0",
},
],
[
{
pkgname: "spark-weather",
source: "apm",
currentVersion: "1.8.0",
nextVersion: "3.0.0",
},
],
new Map([["spark-weather", { aptss: true, apm: false }]]),
);
expect(merged).toEqual([
{
pkgname: "spark-weather",
source: "apm",
currentVersion: "1.8.0",
nextVersion: "3.0.0",
isMigration: true,
migrationSource: "aptss",
migrationTarget: "apm",
aptssVersion: "2.0.0",
},
{
pkgname: "spark-weather",
source: "aptss",
currentVersion: "1.9.0",
nextVersion: "2.0.0",
},
]);
});
it("uses Debian-style version ordering for migration decisions", () => {
const merged = mergeUpdateSources(
[
{
pkgname: "spark-browser",
source: "aptss",
currentVersion: "9.0",
nextVersion: "10.0",
},
],
[
{
pkgname: "spark-browser",
source: "apm",
currentVersion: "1:0.9",
nextVersion: "2:1.0",
},
],
new Map([["spark-browser", { aptss: true, apm: false }]]),
);
expect(merged[0]).toMatchObject({
pkgname: "spark-browser",
source: "apm",
isMigration: true,
aptssVersion: "10.0",
});
expect(merged[1]).toMatchObject({
pkgname: "spark-browser",
source: "aptss",
nextVersion: "10.0",
});
});
it("uses Debian epoch ordering in the fallback when dpkg is unavailable", async () => {
vi.resetModules();
vi.doMock("node:child_process", async (importOriginal) => {
const actual =
await importOriginal<typeof import("node:child_process")>();
return {
...actual,
default: actual,
spawnSync: vi.fn(() => ({
status: null,
error: new Error("dpkg unavailable"),
output: null,
pid: 0,
signal: null,
stdout: Buffer.alloc(0),
stderr: Buffer.alloc(0),
})),
};
});
const { mergeUpdateSources: mergeWithFallback } =
await import("../../../../electron/main/backend/update-center/query");
const merged = mergeWithFallback(
[
{
pkgname: "spark-reader",
source: "aptss",
currentVersion: "2.5.0",
nextVersion: "2.9.0",
},
],
[
{
pkgname: "spark-reader",
source: "apm",
currentVersion: "2.0.0",
nextVersion: "3.0.0",
},
],
new Map([["spark-reader", { aptss: true, apm: false }]]),
);
expect(merged[0]).toMatchObject({
pkgname: "spark-reader",
source: "apm",
isMigration: true,
aptssVersion: "2.9.0",
});
vi.doUnmock("node:child_process");
vi.resetModules();
});
it("uses Debian tilde ordering in the fallback when dpkg is unavailable", async () => {
vi.resetModules();
vi.doMock("node:child_process", async (importOriginal) => {
const actual =
await importOriginal<typeof import("node:child_process")>();
return {
...actual,
default: actual,
spawnSync: vi.fn(() => ({
status: null,
error: new Error("dpkg unavailable"),
output: null,
pid: 0,
signal: null,
stdout: Buffer.alloc(0),
stderr: Buffer.alloc(0),
})),
};
});
const { mergeUpdateSources: mergeWithFallback } =
await import("../../../../electron/main/backend/update-center/query");
const merged = mergeWithFallback(
[
{
pkgname: "spark-tilde",
source: "aptss",
currentVersion: "0.9",
nextVersion: "1.0~rc1",
},
],
[
{
pkgname: "spark-tilde",
source: "apm",
currentVersion: "0.9",
nextVersion: "1.0",
},
],
new Map([["spark-tilde", { aptss: true, apm: false }]]),
);
expect(merged[0]).toMatchObject({
pkgname: "spark-tilde",
source: "apm",
isMigration: true,
aptssVersion: "1.0~rc1",
});
vi.doUnmock("node:child_process");
vi.resetModules();
});
it("parses apm list output into normalized apm items", () => {
const output = [
"Listing...",
"spark-music/main 5.0.0 arm64 [upgradable from: 4.5.0]",
"spark-same/main 1.0.0 arm64 [upgradable from: 1.0.0]",
"",
].join("\n");
expect(parseApmUpgradableOutput(output)).toEqual([
{
pkgname: "spark-music",
source: "apm",
currentVersion: "4.5.0",
nextVersion: "5.0.0",
},
]);
});
it("builds installed-source map from dpkg-query and apm list output", () => {
const dpkgOutput = [
"spark-weather\tinstall ok installed",
"spark-weather-data\tdeinstall ok config-files",
"spark-notes\tinstall ok installed",
"",
].join("\n");
const apmInstalledOutput = [
"Listing...",
"spark-weather/main,stable 3.0.0 amd64 [installed]",
"spark-player/main 1.0.0 amd64 [installed,automatic]",
"",
].join("\n");
expect(
Array.from(
buildInstalledSourceMap(dpkgOutput, apmInstalledOutput).entries(),
),
).toEqual([
["spark-weather", { aptss: true, apm: true }],
["spark-notes", { aptss: true, apm: false }],
["spark-player", { aptss: false, apm: true }],
]);
});
});

View File

@@ -0,0 +1,555 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { UpdateCenterItem } from "../../../../electron/main/backend/update-center/types";
import type { UpdateCenterQueue } from "../../../../electron/main/backend/update-center/queue";
import { createUpdateCenterQueue } from "../../../../electron/main/backend/update-center/queue";
import {
createUpdateCenterService,
type UpdateCenterServiceState,
} from "../../../../electron/main/backend/update-center/service";
import { registerUpdateCenterIpc } from "../../../../electron/main/backend/update-center";
const flushPromises = async (): Promise<void> => {
await Promise.resolve();
await Promise.resolve();
};
const electronMock = vi.hoisted(() => ({
getAllWindows: vi.fn(),
}));
vi.mock("electron", () => ({
BrowserWindow: {
getAllWindows: electronMock.getAllWindows,
},
}));
const createItem = (): UpdateCenterItem => ({
pkgname: "spark-weather",
source: "aptss",
currentVersion: "1.0.0",
nextVersion: "2.0.0",
});
describe("update-center/ipc", () => {
beforeEach(() => {
electronMock.getAllWindows.mockReset();
});
it("registers every update-center handler and forwards service calls", async () => {
const handle = vi.fn();
const send = vi.fn();
const snapshot: UpdateCenterServiceState = {
items: [],
tasks: [],
warnings: [],
hasRunningTasks: false,
};
let listener:
| ((nextSnapshot: UpdateCenterServiceState) => void)
| undefined;
const service = {
open: vi.fn().mockResolvedValue(snapshot),
refresh: vi.fn().mockResolvedValue(snapshot),
ignore: vi.fn().mockResolvedValue(undefined),
unignore: vi.fn().mockResolvedValue(undefined),
start: vi.fn().mockResolvedValue(undefined),
cancel: vi.fn().mockResolvedValue(undefined),
getState: vi.fn().mockReturnValue(snapshot),
subscribe: vi.fn(
(nextListener: (nextSnapshot: UpdateCenterServiceState) => void) => {
listener = nextListener;
return () => undefined;
},
),
};
electronMock.getAllWindows.mockReturnValue([{ webContents: { send } }]);
registerUpdateCenterIpc({ handle }, service);
expect(handle).toHaveBeenCalledWith(
"update-center-open",
expect.any(Function),
);
expect(handle).toHaveBeenCalledWith(
"update-center-refresh",
expect.any(Function),
);
expect(handle).toHaveBeenCalledWith(
"update-center-ignore",
expect.any(Function),
);
expect(handle).toHaveBeenCalledWith(
"update-center-unignore",
expect.any(Function),
);
expect(handle).toHaveBeenCalledWith(
"update-center-start",
expect.any(Function),
);
expect(handle).toHaveBeenCalledWith(
"update-center-cancel",
expect.any(Function),
);
expect(handle).toHaveBeenCalledWith(
"update-center-get-state",
expect.any(Function),
);
expect(service.subscribe).toHaveBeenCalledTimes(1);
const openHandler = handle.mock.calls.find(
([channel]: [string]) => channel === "update-center-open",
)?.[1] as
| ((event: unknown) => Promise<UpdateCenterServiceState>)
| undefined;
const refreshHandler = handle.mock.calls.find(
([channel]: [string]) => channel === "update-center-refresh",
)?.[1] as
| ((event: unknown) => Promise<UpdateCenterServiceState>)
| undefined;
const ignoreHandler = handle.mock.calls.find(
([channel]: [string]) => channel === "update-center-ignore",
)?.[1] as
| ((
event: unknown,
payload: { packageName: string; newVersion: string },
) => Promise<void>)
| undefined;
const unignoreHandler = handle.mock.calls.find(
([channel]: [string]) => channel === "update-center-unignore",
)?.[1] as
| ((
event: unknown,
payload: { packageName: string; newVersion: string },
) => Promise<void>)
| undefined;
const startHandler = handle.mock.calls.find(
([channel]: [string]) => channel === "update-center-start",
)?.[1] as
| ((event: unknown, taskKeys: string[]) => Promise<void>)
| undefined;
const cancelHandler = handle.mock.calls.find(
([channel]: [string]) => channel === "update-center-cancel",
)?.[1] as ((event: unknown, taskKey: string) => Promise<void>) | undefined;
const getStateHandler = handle.mock.calls.find(
([channel]: [string]) => channel === "update-center-get-state",
)?.[1] as (() => UpdateCenterServiceState) | undefined;
await openHandler?.({});
await refreshHandler?.({});
await ignoreHandler?.(
{},
{ packageName: "spark-weather", newVersion: "2.0.0" },
);
await unignoreHandler?.(
{},
{ packageName: "spark-weather", newVersion: "2.0.0" },
);
await startHandler?.({}, ["aptss:spark-weather"]);
await cancelHandler?.({}, "aptss:spark-weather");
expect(getStateHandler?.()).toEqual(snapshot);
expect(service.open).toHaveBeenCalledTimes(1);
expect(service.refresh).toHaveBeenCalledTimes(1);
expect(service.ignore).toHaveBeenCalledWith({
packageName: "spark-weather",
newVersion: "2.0.0",
});
expect(service.unignore).toHaveBeenCalledWith({
packageName: "spark-weather",
newVersion: "2.0.0",
});
expect(service.start).toHaveBeenCalledWith(["aptss:spark-weather"]);
expect(service.cancel).toHaveBeenCalledWith("aptss:spark-weather");
listener?.(snapshot);
expect(send).toHaveBeenCalledWith("update-center-state", snapshot);
});
it("service subscribers receive state updates after refresh start and ignore", async () => {
let ignoredEntries = new Set<string>();
const service = createUpdateCenterService({
loadItems: async () => [createItem()],
loadIgnoredEntries: async () => new Set(ignoredEntries),
saveIgnoredEntries: async (entries: ReadonlySet<string>) => {
ignoredEntries = new Set(entries);
},
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;
},
}),
});
const snapshots: UpdateCenterServiceState[] = [];
service.subscribe((snapshot: UpdateCenterServiceState) => {
snapshots.push(snapshot);
});
await service.refresh();
await service.start(["aptss:spark-weather"]);
await service.ignore({ packageName: "spark-weather", newVersion: "2.0.0" });
expect(snapshots.some((snapshot) => snapshot.hasRunningTasks)).toBe(true);
expect(
snapshots.some((snapshot) =>
snapshot.tasks.some(
(task: UpdateCenterServiceState["tasks"][number]) =>
task.taskKey === "aptss:spark-weather" &&
task.status === "completed",
),
),
).toBe(true);
expect(snapshots.at(-1)?.items[0]).toMatchObject({
taskKey: "aptss:spark-weather",
ignored: true,
newVersion: "2.0.0",
});
expect(snapshots.at(-1)?.items[0]).not.toHaveProperty("nextVersion");
});
it("concurrent start calls still serialize through one processing pipeline", async () => {
const startedTaskIds: number[] = [];
const releases: Array<() => void> = [];
const service = createUpdateCenterService({
loadItems: async () => [
createItem(),
{ ...createItem(), pkgname: "spark-clock" },
],
createTaskRunner: (queue: UpdateCenterQueue) => ({
cancelActiveTask: vi.fn(),
runNextTask: async () => {
const task = queue.getNextQueuedTask();
if (!task) {
return null;
}
startedTaskIds.push(task.id);
queue.markActiveTask(task.id, "installing");
await new Promise<void>((resolve) => {
releases.push(resolve);
});
queue.finishTask(task.id, "completed");
return task;
},
}),
});
await service.refresh();
const firstStart = service.start(["aptss:spark-weather"]);
const secondStart = service.start(["aptss:spark-clock"]);
await flushPromises();
expect(startedTaskIds).toEqual([1]);
releases.shift()?.();
await flushPromises();
expect(startedTaskIds).toEqual([1, 2]);
releases.shift()?.();
await Promise.all([firstStart, secondStart]);
expect(service.getState().tasks).toMatchObject([
{ taskKey: "aptss:spark-weather", status: "completed" },
{ taskKey: "aptss:spark-clock", status: "completed" },
]);
});
it("cancelling an active task stops it and leaves it cancelled", async () => {
let releaseTask: (() => void) | undefined;
const cancelActiveTask = vi.fn(() => {
releaseTask?.();
});
const service = createUpdateCenterService({
loadItems: async () => [createItem()],
createTaskRunner: (queue: UpdateCenterQueue) => ({
cancelActiveTask,
runNextTask: async () => {
const task = queue.getNextQueuedTask();
if (!task) {
return null;
}
queue.markActiveTask(task.id, "installing");
await new Promise<void>((resolve) => {
releaseTask = resolve;
});
return (
queue.getSnapshot().tasks.find((entry) => entry.id === task.id) ??
null
);
},
}),
});
await service.refresh();
const startPromise = service.start(["aptss:spark-weather"]);
await flushPromises();
await service.cancel("aptss:spark-weather");
await startPromise;
expect(cancelActiveTask).toHaveBeenCalledTimes(1);
expect(service.getState().tasks).toMatchObject([
{ taskKey: "aptss:spark-weather", status: "cancelled" },
]);
});
it("cancelling a queued task does not abort the currently active task", async () => {
let releaseTask: (() => void) | undefined;
const cancelActiveTask = vi.fn(() => {
releaseTask?.();
});
const service = createUpdateCenterService({
loadItems: async () => [
createItem(),
{ ...createItem(), pkgname: "spark-clock" },
],
createTaskRunner: (queue: UpdateCenterQueue) => ({
cancelActiveTask,
runNextTask: async () => {
const task = queue.getNextQueuedTask();
if (!task) {
return null;
}
queue.markActiveTask(task.id, "installing");
await new Promise<void>((resolve) => {
releaseTask = resolve;
});
if (
queue.getSnapshot().tasks.find((entry) => entry.id === task.id)
?.status !== "cancelled"
) {
queue.finishTask(task.id, "completed");
}
return task;
},
}),
});
await service.refresh();
const activeStart = service.start(["aptss:spark-weather"]);
await flushPromises();
const queuedStart = service.start(["aptss:spark-clock"]);
await flushPromises();
await service.cancel("aptss:spark-clock");
expect(cancelActiveTask).not.toHaveBeenCalled();
releaseTask?.();
await Promise.all([activeStart, queuedStart]);
expect(service.getState().tasks).toMatchObject([
{ taskKey: "aptss:spark-weather", status: "completed" },
{ taskKey: "aptss:spark-clock", status: "cancelled" },
]);
});
it("superUserCmdProvider failure does not leave a task stuck in queued state", async () => {
const service = createUpdateCenterService({
loadItems: async () => [createItem()],
superUserCmdProvider: async () => {
throw new Error("pkexec unavailable");
},
createTaskRunner: () => ({
cancelActiveTask: vi.fn(),
runNextTask: async () => {
throw new Error(
"runner should not start when privilege lookup fails",
);
},
}),
});
await service.refresh();
await service.start(["aptss:spark-weather"]);
expect(service.getState()).toMatchObject({
hasRunningTasks: false,
tasks: [
{
taskKey: "aptss:spark-weather",
status: "failed",
errorMessage: "pkexec unavailable",
},
],
});
});
it("refresh exposes load-item failures as warnings", async () => {
const service = createUpdateCenterService({
loadItems: async () => {
throw new Error("apt list failed");
},
});
const snapshot = await service.refresh();
expect(snapshot).toMatchObject({
items: [],
warnings: ["apt list failed"],
hasRunningTasks: false,
});
});
it("refresh failure clears previously loaded items so stale updates are not actionable", async () => {
let shouldFailRefresh = false;
const service = createUpdateCenterService({
loadItems: async () => {
if (shouldFailRefresh) {
throw new Error("apt list failed");
}
return [createItem()];
},
});
expect(await service.refresh()).toMatchObject({
items: [{ taskKey: "aptss:spark-weather" }],
warnings: [],
});
shouldFailRefresh = true;
expect(await service.refresh()).toMatchObject({
items: [],
warnings: ["apt list failed"],
hasRunningTasks: false,
});
});
it("refresh preserves warnings returned alongside successful items", async () => {
const service = createUpdateCenterService({
loadItems: async () => ({
items: [createItem()],
warnings: ["apm unavailable, showing aptss updates only"],
}),
});
const snapshot = await service.refresh();
expect(snapshot).toMatchObject({
items: [
{
taskKey: "aptss:spark-weather",
packageName: "spark-weather",
},
],
warnings: ["apm unavailable, showing aptss updates only"],
hasRunningTasks: false,
});
});
it("window ipcRenderer typing matches the preload facade only", async () => {
type IpcFacade = Window["ipcRenderer"];
type HasOn = IpcFacade extends { on: (...args: never[]) => unknown }
? true
: false;
type HasInvoke = IpcFacade extends { invoke: (...args: never[]) => unknown }
? true
: false;
type HasPostMessage = IpcFacade extends {
postMessage: (...args: never[]) => unknown;
}
? true
: false;
const typeShape: [HasOn, HasInvoke, HasPostMessage] = [true, true, false];
expect(typeShape).toEqual([true, true, false]);
});
it("default task runner forwards abort signals into download and install helpers", async () => {
vi.resetModules();
let downloadSignal: AbortSignal | undefined;
let installAborted = false;
let closeHandler: ((code: number | null) => void) | undefined;
vi.doMock(
"../../../../electron/main/backend/update-center/download",
() => ({
runAria2Download: vi.fn(async (context: { signal?: AbortSignal }) => {
downloadSignal = context.signal;
return { filePath: "/tmp/spark-weather.deb" };
}),
}),
);
vi.doMock("node:child_process", () => {
const spawn = vi.fn(() => {
const child = {
stdout: { on: vi.fn() },
stderr: { on: vi.fn() },
kill: vi.fn(() => {
installAborted = true;
closeHandler?.(1);
}),
on: vi.fn(
(event: string, callback: (code: number | null) => void) => {
if (event === "close") {
closeHandler = callback;
}
},
),
};
return child;
});
return {
default: { spawn },
spawn,
};
});
const { createTaskRunner } =
await import("../../../../electron/main/backend/update-center/install");
const queue = createUpdateCenterQueue();
const item = {
...createItem(),
downloadUrl: "https://example.invalid/spark-weather.deb",
fileName: "spark-weather.deb",
};
queue.setItems([item]);
queue.enqueueItem(item);
const runner = createTaskRunner(queue);
const runPromise = runner.runNextTask();
await flushPromises();
expect(downloadSignal).toBeInstanceOf(AbortSignal);
runner.cancelActiveTask();
const settled = await Promise.race([
runPromise.then(() => true),
new Promise<boolean>((resolve) => {
setTimeout(() => resolve(false), 50);
}),
]);
expect(installAborted).toBe(true);
expect(settled).toBe(true);
vi.doUnmock("../../../../electron/main/backend/update-center/download");
vi.doUnmock("node:child_process");
vi.resetModules();
});
});

View File

@@ -0,0 +1,232 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createUpdateCenterStore } from "@/modules/updateCenter";
const createSnapshot = (overrides = {}) => ({
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,
},
],
tasks: [],
warnings: [],
hasRunningTasks: false,
...overrides,
});
describe("updateCenter store", () => {
const open = vi.fn();
const refresh = vi.fn();
const start = vi.fn();
const onState = vi.fn();
const offState = vi.fn();
beforeEach(() => {
open.mockReset();
refresh.mockReset();
start.mockReset();
onState.mockReset();
offState.mockReset();
Object.defineProperty(window, "updateCenter", {
configurable: true,
value: {
open,
refresh,
ignore: vi.fn(),
unignore: vi.fn(),
start,
cancel: vi.fn(),
getState: vi.fn(),
onState,
offState,
},
});
});
it("opens the modal with the initial snapshot", async () => {
const snapshot = createSnapshot();
open.mockResolvedValue(snapshot);
const store = createUpdateCenterStore();
await store.open();
expect(open).toHaveBeenCalledTimes(1);
expect(store.isOpen.value).toBe(true);
expect(store.snapshot.value).toEqual(snapshot);
expect(store.filteredItems.value).toEqual(snapshot.items);
});
it("starts only the selected non-ignored items", 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,
},
{
taskKey: "apm:spark-clock",
packageName: "spark-clock",
displayName: "Spark Clock",
currentVersion: "1.0.0",
newVersion: "2.0.0",
source: "apm" as const,
ignored: true,
},
],
});
open.mockResolvedValue(snapshot);
const store = createUpdateCenterStore();
await store.open();
store.toggleSelection("aptss:spark-weather");
store.toggleSelection("apm:spark-clock");
await store.startSelected();
expect(start).toHaveBeenCalledWith(["aptss:spark-weather"]);
});
it("blocks close requests while the snapshot reports running tasks", () => {
const store = createUpdateCenterStore();
store.isOpen.value = true;
store.snapshot.value = createSnapshot({ hasRunningTasks: true });
store.requestClose();
expect(store.isOpen.value).toBe(true);
expect(store.showCloseConfirm.value).toBe(true);
});
it("applies pushed snapshots from the main process", () => {
let listener:
| ((snapshot: ReturnType<typeof createSnapshot>) => void)
| null = null;
onState.mockImplementation((nextListener) => {
listener = nextListener;
});
const store = createUpdateCenterStore();
store.bind();
const pushedSnapshot = createSnapshot({
items: [
{
taskKey: "aptss:spark-music",
packageName: "spark-music",
displayName: "Spark Music",
currentVersion: "3.0.0",
newVersion: "3.1.0",
source: "aptss" as const,
ignored: false,
},
],
});
listener?.(pushedSnapshot);
expect(onState).toHaveBeenCalledTimes(1);
expect(store.snapshot.value).toEqual(pushedSnapshot);
expect(store.filteredItems.value).toEqual(pushedSnapshot.items);
store.unbind();
expect(offState).toHaveBeenCalledTimes(1);
});
it("prunes stale selected task keys when snapshots change", async () => {
open.mockResolvedValue(
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,
},
{
taskKey: "apm:spark-clock",
packageName: "spark-clock",
displayName: "Spark Clock",
currentVersion: "1.0.0",
newVersion: "2.0.0",
source: "apm" as const,
ignored: false,
},
],
}),
);
refresh.mockResolvedValue(
createSnapshot({
items: [
{
taskKey: "aptss:spark-music",
packageName: "spark-music",
displayName: "Spark Music",
currentVersion: "3.0.0",
newVersion: "3.1.0",
source: "aptss" as const,
ignored: false,
},
],
}),
);
const store = createUpdateCenterStore();
await store.open();
store.toggleSelection("aptss:spark-weather");
expect(store.selectedTaskKeys.value.has("aptss:spark-weather")).toBe(true);
await store.refresh();
expect(store.selectedTaskKeys.value.has("aptss:spark-weather")).toBe(false);
await store.startSelected();
expect(start).not.toHaveBeenCalled();
});
it("clears selection across a close and reopen cycle", 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,
},
],
});
open.mockResolvedValue(snapshot);
const store = createUpdateCenterStore();
await store.open();
store.toggleSelection("aptss:spark-weather");
expect(store.selectedTaskKeys.value.has("aptss:spark-weather")).toBe(true);
store.requestClose();
expect(store.isOpen.value).toBe(false);
await store.open();
expect(store.selectedTaskKeys.value.has("aptss:spark-weather")).toBe(false);
await store.startSelected();
expect(start).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,303 @@
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";
const childProcessMock = vi.hoisted(() => ({
spawnCalls: [] as Array<{ command: string; args: string[] }>,
}));
vi.mock("node:child_process", () => ({
default: {
spawn: vi.fn((command: string, args: string[] = []) => {
childProcessMock.spawnCalls.push({ command, args });
return {
stdout: { on: vi.fn() },
stderr: { on: vi.fn() },
on: vi.fn(
(
event: string,
callback: ((code?: number) => void) | (() => void),
) => {
if (event === "close") {
callback(0);
}
},
),
};
}),
},
spawn: vi.fn((command: string, args: string[] = []) => {
childProcessMock.spawnCalls.push({ command, args });
return {
stdout: { on: vi.fn() },
stderr: { on: vi.fn() },
on: vi.fn(
(event: string, callback: ((code?: number) => void) | (() => void)) => {
if (event === "close") {
callback(0);
}
},
),
};
}),
}));
const createAptssItem = (): UpdateCenterItem => ({
pkgname: "spark-weather",
source: "aptss",
currentVersion: "1.0.0",
nextVersion: "2.0.0",
downloadUrl: "https://example.invalid/spark-weather_2.0.0_amd64.deb",
fileName: "spark-weather_2.0.0_amd64.deb",
});
const createApmItem = (): UpdateCenterItem => ({
pkgname: "spark-player",
source: "apm",
currentVersion: "1.0.0",
nextVersion: "2.0.0",
downloadUrl: "https://example.invalid/spark-player_2.0.0_amd64.deb",
fileName: "spark-player_2.0.0_amd64.deb",
});
describe("update-center task runner", () => {
it("runs download then install and marks the task as completed", async () => {
const queue = createUpdateCenterQueue();
const item = createAptssItem();
const steps: string[] = [];
queue.setItems([item]);
const task = queue.enqueueItem(item);
const runner = createTaskRunner(queue, {
runDownload: async (context) => {
steps.push(`download:${context.task.pkgname}`);
context.onLog("download-started");
context.onProgress(40);
return {
filePath: `/tmp/${context.item.fileName}`,
};
},
installItem: async (context) => {
steps.push(`install:${context.task.pkgname}`);
context.onLog("install-started");
},
});
await runner.runNextTask();
expect(steps).toEqual(["download:spark-weather", "install:spark-weather"]);
expect(queue.getSnapshot()).toMatchObject({
hasRunningTasks: false,
warnings: [],
tasks: [
{
id: task.id,
pkgname: "spark-weather",
status: "completed",
progress: 100,
logs: [
expect.objectContaining({ message: "download-started" }),
expect.objectContaining({ message: "install-started" }),
],
},
],
});
});
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();
expect(queue.getSnapshot().hasRunningTasks).toBe(false);
queue.startRefresh();
expect(queue.getSnapshot().hasRunningTasks).toBe(true);
queue.finishRefresh(["metadata warning"]);
expect(queue.getSnapshot()).toMatchObject({
hasRunningTasks: false,
warnings: ["metadata warning"],
});
const task = queue.enqueueItem(item);
queue.markActiveTask(task.id, "downloading");
expect(queue.getSnapshot().hasRunningTasks).toBe(true);
queue.finishTask(task.id, "cancelled");
expect(queue.getSnapshot().hasRunningTasks).toBe(false);
});
it("propagates privilege escalation into the install path", async () => {
const queue = createUpdateCenterQueue();
const item = createAptssItem();
const installCalls: Array<{ superUserCmd?: string; filePath?: string }> =
[];
queue.setItems([item]);
queue.enqueueItem(item);
const runner = createTaskRunner(queue, {
superUserCmd: "/usr/bin/pkexec",
runDownload: async () => ({ filePath: "/tmp/spark-weather.deb" }),
installItem: async (context) => {
installCalls.push({
superUserCmd: context.superUserCmd,
filePath: context.filePath,
});
},
});
await runner.runNextTask();
expect(installCalls).toEqual([
{
superUserCmd: "/usr/bin/pkexec",
filePath: "/tmp/spark-weather.deb",
},
]);
});
it("fails fast for apm items without a file path", async () => {
const queue = createUpdateCenterQueue();
const item = {
...createApmItem(),
downloadUrl: undefined,
fileName: undefined,
};
queue.setItems([item]);
const task = queue.enqueueItem(item);
const runner = createTaskRunner(queue, {
installItem: async (context) => {
throw new Error(`unexpected install for ${context.item.pkgname}`);
},
});
await runner.runNextTask();
expect(queue.getSnapshot()).toMatchObject({
hasRunningTasks: false,
tasks: [
{
id: task.id,
status: "failed",
error: "APM update task requires downloaded package metadata",
},
],
});
});
it("does not duplicate work across concurrent runNextTask calls", async () => {
const queue = createUpdateCenterQueue();
const item = createAptssItem();
let releaseDownload: (() => void) | undefined;
const downloadGate = new Promise<void>((resolve) => {
releaseDownload = resolve;
});
const runDownload = vi.fn(async () => {
await downloadGate;
return { filePath: "/tmp/spark-weather.deb" };
});
const installItem = vi.fn(async () => {});
queue.setItems([item]);
queue.enqueueItem(item);
const runner = createTaskRunner(queue, {
runDownload,
installItem,
});
const firstRun = runner.runNextTask();
const secondRun = runner.runNextTask();
releaseDownload?.();
const results = await Promise.all([firstRun, secondRun]);
expect(runDownload).toHaveBeenCalledTimes(1);
expect(installItem).toHaveBeenCalledTimes(1);
expect(results[1]).toBeNull();
});
it("marks the task as failed when install fails", async () => {
const queue = createUpdateCenterQueue();
const item = createAptssItem();
queue.setItems([item]);
const task = queue.enqueueItem(item);
const runner = createTaskRunner(queue, {
runDownload: async (context) => {
context.onProgress(30);
return { filePath: "/tmp/spark-weather.deb" };
},
installItem: async () => {
throw new Error("install exploded");
},
});
await runner.runNextTask();
expect(queue.getSnapshot()).toMatchObject({
hasRunningTasks: false,
tasks: [
{
id: task.id,
status: "failed",
progress: 30,
error: "install exploded",
logs: [expect.objectContaining({ message: "install exploded" })],
},
],
});
});
it("does not fall through to ssinstall for apm file installs", async () => {
childProcessMock.spawnCalls.length = 0;
await installUpdateItem({
item: createApmItem(),
filePath: "/tmp/spark-player.deb",
superUserCmd: "/usr/bin/pkexec",
});
expect(childProcessMock.spawnCalls).toEqual([
{
command: "/usr/bin/pkexec",
args: [
"/opt/spark-store/extras/shell-caller.sh",
"apm",
"ssaudit",
"/tmp/spark-player.deb",
],
},
]);
});
});

View File

@@ -442,7 +442,10 @@
import { computed, useAttrs, ref, watch } from "vue";
import axios from "axios";
import { useInstallFeedback, downloads } from "../global/downloadStatus";
import { APM_STORE_BASE_URL, getHybridDefaultOrigin } from "../global/storeConfig";
import {
APM_STORE_BASE_URL,
getHybridDefaultOrigin,
} from "../global/storeConfig";
import type { App } from "../global/typedefinition";
const attrs = useAttrs();

View File

@@ -1,145 +0,0 @@
<template>
<Transition
enter-active-class="duration-200 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="duration-150 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-if="show"
class="fixed inset-0 z-50 flex items-start justify-center bg-slate-900/70 px-4 py-10"
>
<div
class="w-full max-w-4xl max-h-[85vh] overflow-y-auto scrollbar-nowidth rounded-3xl border border-white/10 bg-white/95 p-6 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
>
<div class="flex flex-wrap items-center gap-3">
<div class="flex-1">
<p class="text-2xl font-semibold text-slate-900 dark:text-white">
软件更新
</p>
<p class="text-sm text-slate-500 dark:text-slate-400">
可更新的 APM 应用
</p>
</div>
<div class="flex flex-wrap gap-2">
<button
type="button"
class="inline-flex items-center gap-2 rounded-2xl border border-slate-200/70 px-4 py-2 text-sm font-semibold text-slate-600 transition hover:bg-slate-50 disabled:opacity-40 dark:border-slate-700 dark:text-slate-200"
:disabled="loading"
@click="$emit('refresh')"
>
<i class="fas fa-sync-alt"></i>
刷新
</button>
<button
type="button"
class="inline-flex items-center gap-2 rounded-2xl border border-slate-200/70 px-4 py-2 text-sm font-semibold text-slate-600 transition hover:bg-slate-50 disabled:opacity-40 dark:border-slate-700 dark:text-slate-200"
:disabled="loading || apps.length === 0"
@click="$emit('toggle-all')"
>
<i class="fas fa-check-square"></i>
全选/全不选
</button>
<button
type="button"
class="inline-flex items-center gap-2 rounded-2xl bg-gradient-to-r from-brand to-brand-dark px-4 py-2 text-sm font-semibold text-white shadow-lg disabled:opacity-40"
:disabled="loading || !hasSelected"
@click="$emit('upgrade-selected')"
>
<i class="fas fa-upload"></i>
更新选中
</button>
<button
type="button"
class="inline-flex h-10 w-10 items-center justify-center rounded-full border border-slate-200/70 text-slate-500 transition hover:text-slate-900 dark:border-slate-700"
@click="$emit('close')"
aria-label="关闭"
>
<i class="fas fa-xmark"></i>
</button>
</div>
</div>
<div class="mt-6 space-y-4">
<div
v-if="loading"
class="rounded-2xl border border-dashed border-slate-200/80 px-4 py-10 text-center text-slate-500 dark:border-slate-800/80 dark:text-slate-400"
>
正在检查可更新应用
</div>
<div
v-else-if="error"
class="rounded-2xl border border-rose-200/70 bg-rose-50/60 px-4 py-6 text-center text-sm text-rose-600 dark:border-rose-500/40 dark:bg-rose-500/10"
>
{{ error }}
</div>
<div
v-else-if="apps.length === 0"
class="rounded-2xl border border-slate-200/70 px-4 py-10 text-center text-slate-500 dark:border-slate-800/70 dark:text-slate-400"
>
暂无可更新应用
</div>
<div v-else class="space-y-3">
<label
v-for="app in apps"
:key="app.pkgname"
class="flex flex-col gap-3 rounded-2xl border border-slate-200/70 bg-white/90 p-4 shadow-sm dark:border-slate-800/70 dark:bg-slate-900/70 sm:flex-row sm:items-center sm:gap-4"
>
<div class="flex items-start gap-3">
<input
type="checkbox"
class="mt-1 h-4 w-4 rounded border-slate-300 accent-brand focus:ring-brand"
v-model="app.selected"
:disabled="app.upgrading"
/>
<div>
<p class="font-semibold text-slate-900 dark:text-white">
{{ app.pkgname }}
</p>
<p class="text-sm text-slate-500 dark:text-slate-400">
当前 {{ app.currentVersion || "-" }} · 更新至
{{ app.newVersion || "-" }}
</p>
</div>
</div>
<div class="flex items-center gap-2 sm:ml-auto">
<button
type="button"
class="inline-flex items-center gap-2 rounded-2xl border border-slate-200/70 px-4 py-2 text-sm font-semibold text-slate-600 transition hover:bg-slate-50 disabled:opacity-40 dark:border-slate-700 dark:text-slate-200"
:disabled="app.upgrading"
@click.prevent="$emit('upgrade-one', app)"
>
<i class="fas fa-arrow-up"></i>
{{ app.upgrading ? "更新中…" : "更新" }}
</button>
</div>
</label>
</div>
</div>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
import type { UpdateAppItem } from "../global/typedefinition";
defineProps<{
show: boolean;
apps: UpdateAppItem[];
loading: boolean;
error: string;
hasSelected: boolean;
}>();
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const emit = defineEmits<{
(e: "close"): void;
(e: "refresh"): void;
(e: "toggle-all"): void;
(e: "upgrade-selected"): void;
(e: "upgrade-one", app: UpdateAppItem): void;
}>();
</script>

View File

@@ -0,0 +1,93 @@
<template>
<Transition
enter-active-class="duration-200 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="duration-150 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<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"
>
<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"
>
<UpdateCenterToolbar
:search-query="store.searchQuery.value"
:selected-count="selectedCount"
@refresh="store.refresh"
@start-selected="emit('request-start-selected')"
@request-close="store.requestClose"
@update:search-query="emit('update:search-query', $event)"
/>
<div
v-if="store.snapshot.value.warnings.length > 0"
class="mx-6 mt-4 rounded-2xl border border-amber-200 bg-amber-50/80 px-4 py-3 text-sm text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100"
>
<p
v-for="warning in store.snapshot.value.warnings"
:key="warning"
class="leading-6"
>
{{ warning }}
</p>
</div>
<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
:show="store.showMigrationConfirm.value"
@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>
</template>
<script setup lang="ts">
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";
const emit = defineEmits<{
(e: "update:search-query", value: string): void;
(e: "toggle-selection", taskKey: string): void;
(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<{
show: boolean;
store: UpdateCenterStore;
}>();
const selectedCount = computed(() => props.store.getSelectedItems().length);
</script>

View File

@@ -0,0 +1,44 @@
<template>
<div
v-if="show"
class="absolute inset-0 flex items-center justify-center bg-slate-950/45 px-4"
>
<div
class="w-full max-w-md rounded-3xl border border-amber-200 bg-white p-6 shadow-2xl dark:border-amber-500/30 dark:bg-slate-900"
>
<p class="text-lg font-semibold text-slate-900 dark:text-white">
关闭确认
</p>
<p class="mt-2 text-sm text-slate-600 dark:text-slate-300">
更新仍在进行中确定关闭此窗口吗
</p>
<div class="mt-4 flex justify-end gap-3">
<button
type="button"
class="rounded-2xl border border-slate-200/70 px-4 py-2 text-sm font-semibold text-slate-600 dark:border-slate-700 dark:text-slate-200"
@click="$emit('close')"
>
继续查看
</button>
<button
type="button"
class="rounded-2xl bg-amber-500 px-4 py-2 text-sm font-semibold text-white"
@click="$emit('confirm')"
>
确认关闭
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
show: boolean;
}>();
defineEmits<{
(e: "close"): void;
(e: "confirm"): void;
}>();
</script>

View File

@@ -0,0 +1,116 @@
<template>
<label
class="flex flex-col gap-4 rounded-2xl border border-slate-200/70 bg-white/90 p-4 shadow-sm dark:border-slate-800/70 dark:bg-slate-900/70"
>
<div class="flex items-start gap-3">
<input
type="checkbox"
class="mt-1 h-4 w-4 rounded border-slate-300 accent-brand focus:ring-brand"
:checked="selected"
:disabled="item.ignored === true"
@change="$emit('toggle-selection')"
/>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<p class="font-semibold text-slate-900 dark:text-white">
{{ item.displayName }}
</p>
<span
class="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-semibold text-slate-600 dark:bg-slate-800 dark:text-slate-200"
>
{{ sourceLabel }}
</span>
<span
v-if="item.isMigration"
class="rounded-full bg-brand/10 px-2.5 py-1 text-xs font-semibold text-brand"
>
将迁移到 APM
</span>
<span
v-if="item.ignored === true"
class="rounded-full bg-slate-200 px-2.5 py-1 text-xs font-semibold text-slate-500 dark:bg-slate-800 dark:text-slate-300"
>
已忽略
</span>
</div>
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">
{{ item.packageName }} · 当前 {{ item.currentVersion }} · 更新至
{{ item.newVersion }}
</p>
<p
v-if="item.ignored === true"
class="mt-2 text-xs font-medium text-slate-500 dark:text-slate-400"
>
已忽略的更新不会加入本次任务
</p>
</div>
<div
v-if="task"
class="text-right text-sm font-semibold text-slate-600 dark:text-slate-300"
>
<p>{{ statusLabel }}</p>
<p v-if="showProgress" class="mt-1">{{ progressText }}</p>
</div>
</div>
<div v-if="showProgress" class="space-y-2">
<div
class="h-2 overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800"
>
<div
class="h-full rounded-full bg-gradient-to-r from-brand to-brand-dark"
:style="progressStyle"
></div>
</div>
</div>
</label>
</template>
<script setup lang="ts">
import { computed } from "vue";
import type {
UpdateCenterItem,
UpdateCenterTaskState,
} from "@/global/typedefinition";
const props = defineProps<{
item: UpdateCenterItem;
task?: UpdateCenterTaskState;
selected: boolean;
}>();
defineEmits<{
(e: "toggle-selection"): void;
}>();
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>

View File

@@ -0,0 +1,47 @@
<template>
<div
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"
class="rounded-2xl border border-dashed border-slate-200/80 px-4 py-10 text-center text-slate-500 dark:border-slate-800/80 dark:text-slate-400"
>
暂无可展示的更新任务
</div>
<div v-else class="space-y-3">
<UpdateCenterItem
v-for="item in items"
:key="item.taskKey"
:item="item"
:task="taskMap.get(item.taskKey)"
:selected="selectedTaskKeys.has(item.taskKey)"
@toggle-selection="$emit('toggle-selection', item.taskKey)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import type {
UpdateCenterItem as UpdateCenterItemModel,
UpdateCenterTaskState,
} from "@/global/typedefinition";
import UpdateCenterItem from "./UpdateCenterItem.vue";
const props = defineProps<{
items: UpdateCenterItemModel[];
tasks: UpdateCenterTaskState[];
selectedTaskKeys: Set<string>;
}>();
defineEmits<{
(e: "toggle-selection", taskKey: string): void;
}>();
const taskMap = computed(() => {
return new Map(props.tasks.map((task) => [task.taskKey, task]));
});
</script>

View File

@@ -0,0 +1,45 @@
<template>
<aside
class="hidden min-h-0 flex-col border-t border-slate-200/70 bg-slate-50/70 p-6 lg:flex lg:border-t-0 dark:border-slate-800/70 dark:bg-slate-950/50"
>
<div class="flex items-center justify-between gap-3">
<p class="text-sm font-semibold text-slate-900 dark:text-white">
任务日志
</p>
<span class="text-xs text-slate-500 dark:text-slate-400"
>{{ tasks.length }} </span
>
</div>
<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"
>
暂无运行日志
</div>
<div
v-for="task in tasks"
:key="task.taskKey"
class="rounded-2xl border border-slate-200/70 bg-white/80 p-4 dark:border-slate-800/70 dark:bg-slate-900/70"
>
<p class="text-sm font-semibold text-slate-900 dark:text-white">
{{ task.packageName }}
</p>
<p class="mt-1 text-xs text-slate-500 dark:text-slate-400">
{{ task.status }}
</p>
<p class="mt-3 text-xs leading-5 text-slate-600 dark:text-slate-300">
{{ task.logs.at(-1)?.message || task.errorMessage || "等待日志输出" }}
</p>
</div>
</div>
</aside>
</template>
<script setup lang="ts">
import type { UpdateCenterTaskState } from "@/global/typedefinition";
defineProps<{
tasks: UpdateCenterTaskState[];
}>();
</script>

View File

@@ -0,0 +1,44 @@
<template>
<div
v-if="show"
class="absolute inset-0 flex items-center justify-center bg-slate-950/45 px-4"
>
<div
class="w-full max-w-md rounded-3xl border border-slate-200/70 bg-white p-6 shadow-2xl dark:border-slate-700 dark:bg-slate-900"
>
<p class="text-lg font-semibold text-slate-900 dark:text-white">
迁移确认
</p>
<p class="mt-2 text-sm text-slate-500 dark:text-slate-400">
部分传统 deb 更新将迁移到 APM 管理
</p>
<div class="mt-4 flex justify-end gap-3">
<button
type="button"
class="rounded-2xl border border-slate-200/70 px-4 py-2 text-sm font-semibold text-slate-600 dark:border-slate-700 dark:text-slate-200"
@click="$emit('close')"
>
取消
</button>
<button
type="button"
class="rounded-2xl bg-gradient-to-r from-brand to-brand-dark px-4 py-2 text-sm font-semibold text-white"
@click="$emit('confirm')"
>
继续更新
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
show: boolean;
}>();
defineEmits<{
(e: "close"): void;
(e: "confirm"): void;
}>();
</script>

View File

@@ -0,0 +1,73 @@
<template>
<div
class="flex flex-col gap-4 border-b border-slate-200/70 px-6 py-5 dark:border-slate-800/70"
>
<div class="flex items-start gap-4">
<div class="flex-1">
<p class="text-2xl font-semibold text-slate-900 dark:text-white">
软件更新
</p>
<p class="text-sm text-slate-500 dark:text-slate-400">
集中管理 APM 与传统 deb 更新任务
</p>
</div>
<div class="flex items-center gap-2">
<button
type="button"
class="inline-flex items-center gap-2 rounded-2xl border border-slate-200/70 px-4 py-2 text-sm font-semibold text-slate-600 transition hover:bg-slate-50 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
@click="$emit('refresh')"
>
<i class="fas fa-sync-alt"></i>
刷新
</button>
<button
type="button"
class="inline-flex items-center gap-2 rounded-2xl bg-gradient-to-r from-brand to-brand-dark px-4 py-2 text-sm font-semibold text-white shadow-lg disabled:opacity-40"
:disabled="selectedCount === 0"
@click="$emit('start-selected')"
>
<i class="fas fa-play"></i>
更新选中 ({{ selectedCount }})
</button>
<button
type="button"
aria-label="关闭"
class="inline-flex h-11 w-11 items-center justify-center rounded-full border border-slate-200/70 text-slate-500 transition hover:text-slate-900 dark:border-slate-700 dark:text-slate-300"
@click="$emit('request-close')"
>
<i class="fas fa-xmark"></i>
</button>
</div>
</div>
<label class="block">
<span class="sr-only">搜索更新</span>
<input
:value="searchQuery"
type="search"
placeholder="搜索应用或包名"
class="w-full rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-brand/60 focus:bg-white dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100 dark:focus:bg-slate-900"
@input="handleInput"
/>
</label>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
searchQuery: string;
selectedCount: number;
}>();
const emit = defineEmits<{
(e: "refresh"): void;
(e: "start-selected"): void;
(e: "request-close"): void;
(e: "update:search-query", value: string): void;
}>();
const handleInput = (event: Event): void => {
const target = event.target as HTMLInputElement | null;
emit("update:search-query", target?.value ?? props.searchQuery);
};
</script>

View File

@@ -102,7 +102,10 @@ export async function loadPriorityConfig(arch: string): Promise<void> {
};
}
isPriorityConfigLoaded = true;
console.log("[PriorityConfig] 已从服务器加载优先级配置:", dynamicPriorityConfig);
console.log(
"[PriorityConfig] 已从服务器加载优先级配置:",
dynamicPriorityConfig,
);
} else {
// 配置文件不存在,默认优先 Spark
console.log("[PriorityConfig] 服务器无配置文件,使用默认 Spark 优先");
@@ -136,21 +139,6 @@ function resetPriorityConfig(): void {
isPriorityConfigLoaded = true;
}
/**
* 检查配置是否为空(没有任何规则)
*/
function isConfigEmpty(): boolean {
const { sparkPriority, apmPriority } = dynamicPriorityConfig;
return (
sparkPriority.pkgnames.length === 0 &&
sparkPriority.categories.length === 0 &&
sparkPriority.tags.length === 0 &&
apmPriority.pkgnames.length === 0 &&
apmPriority.categories.length === 0 &&
apmPriority.tags.length === 0
);
}
/**
* 获取混合模式下应用的默认优先来源
* 判断优先级(从高到低):

View File

@@ -123,6 +123,69 @@ export interface UpdateAppItem {
upgrading?: boolean;
}
export type UpdateSource = "aptss" | "apm";
export type UpdateCenterTaskStatus =
| "queued"
| "downloading"
| "installing"
| "completed"
| "failed"
| "cancelled";
export interface UpdateCenterItem {
taskKey: string;
packageName: string;
displayName: string;
currentVersion: string;
newVersion: string;
source: UpdateSource;
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;
status: UpdateCenterTaskStatus;
progress: number;
logs: Array<{ time: number; message: string }>;
errorMessage: string;
}
export interface UpdateCenterSnapshot {
items: UpdateCenterItem[];
tasks: UpdateCenterTaskState[];
warnings: string[];
hasRunningTasks: boolean;
}
export interface UpdateCenterBridge {
open: () => Promise<UpdateCenterSnapshot>;
refresh: () => Promise<UpdateCenterSnapshot>;
ignore: (payload: {
packageName: string;
newVersion: string;
}) => Promise<void>;
unignore: (payload: {
packageName: string;
newVersion: string;
}) => Promise<void>;
start: (taskKeys: string[]) => Promise<void>;
cancel: (taskKey: string) => Promise<void>;
getState: () => Promise<UpdateCenterSnapshot>;
onState: (listener: (snapshot: UpdateCenterSnapshot) => void) => void;
offState: (listener: (snapshot: UpdateCenterSnapshot) => void) => void;
}
/**************Below are type from main process ********************/
export interface InstalledAppInfo {
pkgname: string;

182
src/modules/updateCenter.ts Normal file
View File

@@ -0,0 +1,182 @@
import { computed, ref, type ComputedRef, type Ref } from "vue";
import type {
UpdateCenterItem,
UpdateCenterSnapshot,
} from "@/global/typedefinition";
const EMPTY_SNAPSHOT: UpdateCenterSnapshot = {
items: [],
tasks: [],
warnings: [],
hasRunningTasks: false,
};
export interface UpdateCenterStore {
isOpen: Ref<boolean>;
showCloseConfirm: Ref<boolean>;
showMigrationConfirm: Ref<boolean>;
searchQuery: Ref<string>;
selectedTaskKeys: Ref<Set<string>>;
snapshot: Ref<UpdateCenterSnapshot>;
filteredItems: ComputedRef<UpdateCenterItem[]>;
bind: () => void;
unbind: () => void;
open: () => Promise<void>;
refresh: () => Promise<void>;
toggleSelection: (taskKey: string) => void;
getSelectedItems: () => UpdateCenterItem[];
closeNow: () => void;
startSelected: () => Promise<void>;
requestClose: () => void;
}
const matchesSearch = (item: UpdateCenterItem, query: string): boolean => {
if (query.length === 0) {
return true;
}
const normalizedQuery = query.toLowerCase();
return [item.displayName, item.packageName, item.taskKey].some((value) =>
value.toLowerCase().includes(normalizedQuery),
);
};
export const createUpdateCenterStore = (): UpdateCenterStore => {
const isOpen = ref(false);
const showCloseConfirm = ref(false);
const showMigrationConfirm = ref(false);
const searchQuery = ref("");
const selectedTaskKeys = ref(new Set<string>());
const snapshot = ref<UpdateCenterSnapshot>(EMPTY_SNAPSHOT);
const resetSessionState = (): void => {
showCloseConfirm.value = false;
showMigrationConfirm.value = false;
searchQuery.value = "";
selectedTaskKeys.value = new Set();
};
const applySnapshot = (nextSnapshot: UpdateCenterSnapshot): void => {
const selectableTaskKeys = new Set(
nextSnapshot.items
.filter((item) => item.ignored !== true)
.map((item) => item.taskKey),
);
selectedTaskKeys.value = new Set(
[...selectedTaskKeys.value].filter((taskKey) =>
selectableTaskKeys.has(taskKey),
),
);
snapshot.value = nextSnapshot;
};
const filteredItems = computed(() => {
const query = searchQuery.value.trim();
return snapshot.value.items.filter((item) => matchesSearch(item, query));
});
const handleState = (nextSnapshot: UpdateCenterSnapshot): void => {
applySnapshot(nextSnapshot);
};
let isBound = false;
const bind = (): void => {
if (isBound) {
return;
}
window.updateCenter.onState(handleState);
isBound = true;
};
const unbind = (): void => {
if (!isBound) {
return;
}
window.updateCenter.offState(handleState);
isBound = false;
};
const open = async (): Promise<void> => {
resetSessionState();
const nextSnapshot = await window.updateCenter.open();
applySnapshot(nextSnapshot);
isOpen.value = true;
};
const refresh = async (): Promise<void> => {
const nextSnapshot = await window.updateCenter.refresh();
applySnapshot(nextSnapshot);
};
const toggleSelection = (taskKey: string): void => {
const item = snapshot.value.items.find(
(entry) => entry.taskKey === taskKey,
);
if (!item || item.ignored === true) {
return;
}
const nextSelection = new Set(selectedTaskKeys.value);
if (nextSelection.has(taskKey)) {
nextSelection.delete(taskKey);
} else {
nextSelection.add(taskKey);
}
selectedTaskKeys.value = nextSelection;
};
const getSelectedItems = (): UpdateCenterItem[] => {
return snapshot.value.items.filter(
(item) =>
selectedTaskKeys.value.has(item.taskKey) && item.ignored !== true,
);
};
const closeNow = (): void => {
resetSessionState();
isOpen.value = false;
};
const startSelected = async (): Promise<void> => {
const taskKeys = getSelectedItems().map((item) => item.taskKey);
if (taskKeys.length === 0) {
return;
}
await window.updateCenter.start(taskKeys);
};
const requestClose = (): void => {
if (snapshot.value.hasRunningTasks) {
showCloseConfirm.value = true;
return;
}
closeNow();
};
return {
isOpen,
showCloseConfirm,
showMigrationConfirm,
searchQuery,
selectedTaskKeys,
snapshot,
filteredItems,
bind,
unbind,
open,
refresh,
toggleSelection,
getSelectedItems,
closeNow,
startSelected,
requestClose,
};
};

47
src/vite-env.d.ts vendored
View File

@@ -1,18 +1,30 @@
/* eslint-disable */
/// <reference types="vite/client" />
import type { UpdateCenterBridge } from "@/global/typedefinition";
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}
interface Window {
// expose in the `electron/preload/index.ts`
ipcRenderer: import("electron").IpcRenderer;
apm_store: {
arch: string;
};
declare global {
interface Window {
// expose in the `electron/preload/index.ts`
ipcRenderer: IpcRendererFacade;
apm_store: {
arch: string;
};
updateCenter: UpdateCenterBridge;
}
}
interface IpcRendererFacade {
on: import("electron").IpcRenderer["on"];
off: import("electron").IpcRenderer["off"];
send: import("electron").IpcRenderer["send"];
invoke: import("electron").IpcRenderer["invoke"];
}
// IPC channel type definitions
@@ -22,25 +34,4 @@ declare interface IpcChannels {
declare const __APP_VERSION__: string;
// vue-virtual-scroller type declarations
declare module "vue-virtual-scroller" {
import { DefineComponent } from "vue";
export const RecycleScroller: DefineComponent<{
items: any[];
itemSize: number;
keyField?: string;
direction?: "vertical" | "horizontal";
buffer?: number;
}>;
export const DynamicScroller: DefineComponent<{
items: any[];
minItemSize: number;
keyField?: string;
direction?: "vertical" | "horizontal";
buffer?: number;
}>;
export const DynamicScrollerItem: DefineComponent<{}>;
}
export {};

21
src/vue-virtual-scroller.d.ts vendored Normal file
View File

@@ -0,0 +1,21 @@
declare module "vue-virtual-scroller" {
import type { DefineComponent } from "vue";
export const RecycleScroller: DefineComponent<{
items: unknown[];
itemSize: number;
keyField?: string;
direction?: "vertical" | "horizontal";
buffer?: number;
}>;
export const DynamicScroller: DefineComponent<{
items: unknown[];
minItemSize: number;
keyField?: string;
direction?: "vertical" | "horizontal";
buffer?: number;
}>;
export const DynamicScrollerItem: DefineComponent<Record<string, never>>;
}