fix(sources): hide unavailable update and management entries

This commit is contained in:
2026-04-16 13:04:54 +08:00
parent e1ec526cb9
commit 0b784af3d7
16 changed files with 667 additions and 58 deletions
+55
View File
@@ -10,6 +10,24 @@ import axios from "axios";
const logger = pino({ name: "install-manager" }); const logger = pino({ name: "install-manager" });
const getStoreFilterFromArgv = (): "spark" | "apm" | "both" => {
const argv = process.argv;
const noApm = argv.includes("--no-apm");
const noSpark = argv.includes("--no-spark");
if (noApm && noSpark) return "both";
if (noApm) return "spark";
if (noSpark) return "apm";
return "both";
};
const isOriginEnabled = (
storeFilter: "spark" | "apm" | "both",
origin: "spark" | "apm",
): boolean => {
return storeFilter === "both" || storeFilter === origin;
};
type InstallTask = { type InstallTask = {
id: number; id: number;
pkgname: string; pkgname: string;
@@ -88,6 +106,14 @@ const checkApmAvailable = async (): Promise<boolean> => {
return found; return found;
}; };
/** 检测本机是否具备 Spark/aptss 管理能力 */
const checkSparkAvailable = async (): Promise<boolean> => {
const { code, stdout } = await runCommandCapture("which", ["aptss"]);
const found = code === 0 && stdout.trim().length > 0;
if (!found) logger.info("未检测到 aptss 命令");
return found;
};
/** 提权执行 shell-caller aptss install apm 安装 APM,安装后需用户重启电脑 */ /** 提权执行 shell-caller aptss install apm 安装 APM,安装后需用户重启电脑 */
const runInstallApm = async (superUserCmd: string): Promise<boolean> => { const runInstallApm = async (superUserCmd: string): Promise<boolean> => {
const execCommand = superUserCmd || SHELL_CALLER_PATH; const execCommand = superUserCmd || SHELL_CALLER_PATH;
@@ -793,8 +819,33 @@ ipcMain.handle(
payload: { origin: "apm" | "spark"; pkgnameList?: string[] }, payload: { origin: "apm" | "spark"; pkgnameList?: string[] },
) => { ) => {
const { origin, pkgnameList } = payload; const { origin, pkgnameList } = payload;
const storeFilter = getStoreFilterFromArgv();
const apmBasePath = "/var/lib/apm/apm/files/ace-env/var/lib/apm"; const apmBasePath = "/var/lib/apm/apm/files/ace-env/var/lib/apm";
if (!isOriginEnabled(storeFilter, origin)) {
return {
success: false,
message: `${origin} origin disabled by startup filter`,
apps: [],
};
}
if (origin === "spark" && !(await checkSparkAvailable())) {
return {
success: false,
message: "spark origin unavailable on this system",
apps: [],
};
}
if (origin === "apm" && !(await checkApmAvailable())) {
return {
success: false,
message: "apm origin unavailable on this system",
apps: [],
};
}
try { try {
const installedApps: Array<{ const installedApps: Array<{
pkgname: string; pkgname: string;
@@ -1033,6 +1084,10 @@ ipcMain.handle("check-apm-available", async () => {
return await checkApmAvailable(); return await checkApmAvailable();
}); });
ipcMain.handle("check-spark-available", async () => {
return await checkSparkAvailable();
});
// 显示 APM 安装对话框(在点击安装按钮时提前检查) // 显示 APM 安装对话框(在点击安装按钮时提前检查)
ipcMain.handle("show-apm-install-dialog", async (event) => { ipcMain.handle("show-apm-install-dialog", async (event) => {
const webContents = event.sender; const webContents = event.sender;
+60 -14
View File
@@ -12,6 +12,7 @@ import {
import { resolveUpdateItemIcons } from "./icons"; import { resolveUpdateItemIcons } from "./icons";
import { import {
createUpdateCenterService, createUpdateCenterService,
type StoreFilter,
type UpdateCenterIgnorePayload, type UpdateCenterIgnorePayload,
type UpdateCenterService, type UpdateCenterService,
type UpdateCenterStartTask, type UpdateCenterStartTask,
@@ -349,35 +350,70 @@ const enrichItemIcons = (items: UpdateCenterItem[]): UpdateCenterItem[] => {
}); });
}; };
const isSourceEnabled = (
storeFilter: StoreFilter,
source: "spark" | "apm",
): boolean => {
return storeFilter === "both" || storeFilter === source;
};
const isCommandAvailable = async (
runCommand: UpdateCenterCommandRunner,
command: "aptss" | "apm",
): Promise<boolean> => {
const result = await runCommand("which", [command]);
return result.code === 0 && result.stdout.trim().length > 0;
};
export const loadUpdateCenterItems = async ( export const loadUpdateCenterItems = async (
runCommand: UpdateCenterCommandRunner = runCommandCapture, runCommand: UpdateCenterCommandRunner = runCommandCapture,
storeFilter: StoreFilter = "both",
): Promise<UpdateCenterLoadItemsResult> => { ): Promise<UpdateCenterLoadItemsResult> => {
const [sparkEnabled, apmEnabled] = await Promise.all([
isSourceEnabled(storeFilter, "spark")
? isCommandAvailable(runCommand, "aptss")
: Promise.resolve(false),
isSourceEnabled(storeFilter, "apm")
? isCommandAvailable(runCommand, "apm")
: Promise.resolve(false),
]);
const [aptssResult, apmResult, aptssInstalledResult, apmInstalledResult] = const [aptssResult, apmResult, aptssInstalledResult, apmInstalledResult] =
await Promise.all([ await Promise.all([
runCommand( sparkEnabled
? runCommand(
APTSS_LIST_UPGRADABLE_COMMAND.command, APTSS_LIST_UPGRADABLE_COMMAND.command,
APTSS_LIST_UPGRADABLE_COMMAND.args, APTSS_LIST_UPGRADABLE_COMMAND.args,
), )
runCommand("apm", ["list", "--upgradable"]), : Promise.resolve({ code: 0, stdout: "", stderr: "" }),
runCommand( apmEnabled
? runCommand("apm", ["list", "--upgradable"])
: Promise.resolve({ code: 0, stdout: "", stderr: "" }),
sparkEnabled
? runCommand(
DPKG_QUERY_INSTALLED_COMMAND.command, DPKG_QUERY_INSTALLED_COMMAND.command,
DPKG_QUERY_INSTALLED_COMMAND.args, DPKG_QUERY_INSTALLED_COMMAND.args,
), )
runCommand("apm", ["list", "--installed"]), : Promise.resolve({ code: 0, stdout: "", stderr: "" }),
apmEnabled
? runCommand("apm", ["list", "--installed"])
: Promise.resolve({ code: 0, stdout: "", stderr: "" }),
]); ]);
const aptssAvailable = const aptssAvailable =
aptssResult.code === 0 || aptssInstalledResult.code === 0; sparkEnabled && (aptssResult.code === 0 || aptssInstalledResult.code === 0);
const warnings = [ const warnings = [
aptssAvailable aptssAvailable
? getCommandError("aptss upgradable query", aptssResult) ? getCommandError("aptss upgradable query", aptssResult)
: null, : null,
getCommandError("apm upgradable query", apmResult), apmEnabled ? getCommandError("apm upgradable query", apmResult) : null,
aptssAvailable aptssAvailable
? getCommandError("dpkg installed query", aptssInstalledResult) ? getCommandError("dpkg installed query", aptssInstalledResult)
: null, : null,
getCommandError("apm installed query", apmInstalledResult), apmEnabled
? getCommandError("apm installed query", apmInstalledResult)
: null,
].filter((message): message is string => message !== null); ].filter((message): message is string => message !== null);
const aptssItems = const aptssItems =
@@ -385,7 +421,9 @@ export const loadUpdateCenterItems = async (
? parseAptssUpgradableOutput(aptssResult.stdout) ? parseAptssUpgradableOutput(aptssResult.stdout)
: []; : [];
const apmItems = const apmItems =
apmResult.code === 0 ? parseApmUpgradableOutput(apmResult.stdout) : []; apmEnabled && apmResult.code === 0
? parseApmUpgradableOutput(apmResult.stdout)
: [];
const installedSources = buildInstalledSourceMap( const installedSources = buildInstalledSourceMap(
aptssAvailable && aptssInstalledResult.code === 0 aptssAvailable && aptssInstalledResult.code === 0
@@ -396,13 +434,15 @@ export const loadUpdateCenterItems = async (
const [categorizedAptssItems, categorizedApmItems] = await Promise.all([ const [categorizedAptssItems, categorizedApmItems] = await Promise.all([
aptssAvailable ? enrichItemCategories(aptssItems) : Promise.resolve([]), aptssAvailable ? enrichItemCategories(aptssItems) : Promise.resolve([]),
enrichItemCategories(apmItems), apmEnabled ? enrichItemCategories(apmItems) : Promise.resolve([]),
]); ]);
const [enrichedAptssItems, enrichedApmItems] = await Promise.all([ const [enrichedAptssItems, enrichedApmItems] = await Promise.all([
aptssAvailable aptssAvailable
? enrichAptssItems(categorizedAptssItems, runCommand) ? enrichAptssItems(categorizedAptssItems, runCommand)
: Promise.resolve({ items: [], warnings: [] }), : Promise.resolve({ items: [], warnings: [] }),
enrichApmItems(categorizedApmItems, runCommand), apmEnabled
? enrichApmItems(categorizedApmItems, runCommand)
: Promise.resolve({ items: [], warnings: [] }),
]); ]);
return { return {
@@ -433,8 +473,14 @@ export const registerUpdateCenterIpc = (
| "subscribe" | "subscribe"
>, >,
): void => { ): void => {
ipc.handle("update-center-open", () => service.open()); ipc.handle(
ipc.handle("update-center-refresh", () => service.refresh()); "update-center-open",
(_event, storeFilter: StoreFilter = "both") => service.open(storeFilter),
);
ipc.handle(
"update-center-refresh",
(_event, storeFilter: StoreFilter = "both") => service.refresh(storeFilter),
);
ipc.handle( ipc.handle(
"update-center-ignore", "update-center-ignore",
(_event, payload: UpdateCenterIgnorePayload) => service.ignore(payload), (_event, payload: UpdateCenterIgnorePayload) => service.ignore(payload),
+15 -5
View File
@@ -13,6 +13,8 @@ import {
} from "./queue"; } from "./queue";
import type { UpdateCenterItem, UpdateSource } from "./types"; import type { UpdateCenterItem, UpdateSource } from "./types";
export type StoreFilter = "spark" | "apm" | "both";
export interface UpdateCenterLoadedItems { export interface UpdateCenterLoadedItems {
items: UpdateCenterItem[]; items: UpdateCenterItem[];
warnings: string[]; warnings: string[];
@@ -68,8 +70,8 @@ export interface UpdateCenterStartTask {
} }
export interface UpdateCenterService { export interface UpdateCenterService {
open: () => Promise<UpdateCenterServiceState>; open: (storeFilter?: StoreFilter) => Promise<UpdateCenterServiceState>;
refresh: () => Promise<UpdateCenterServiceState>; refresh: (storeFilter?: StoreFilter) => Promise<UpdateCenterServiceState>;
ignore: (payload: UpdateCenterIgnorePayload) => Promise<void>; ignore: (payload: UpdateCenterIgnorePayload) => Promise<void>;
unignore: (payload: UpdateCenterIgnorePayload) => Promise<void>; unignore: (payload: UpdateCenterIgnorePayload) => Promise<void>;
start: (tasks: UpdateCenterStartTask[]) => Promise<void>; start: (tasks: UpdateCenterStartTask[]) => Promise<void>;
@@ -81,7 +83,9 @@ export interface UpdateCenterService {
} }
export interface CreateUpdateCenterServiceOptions { export interface CreateUpdateCenterServiceOptions {
loadItems: () => Promise<UpdateCenterItem[] | UpdateCenterLoadedItems>; loadItems: (
storeFilter: StoreFilter,
) => Promise<UpdateCenterItem[] | UpdateCenterLoadedItems>;
loadIgnoredEntries?: () => Promise<Set<string>>; loadIgnoredEntries?: () => Promise<Set<string>>;
saveIgnoredEntries?: (entries: ReadonlySet<string>) => Promise<void>; saveIgnoredEntries?: (entries: ReadonlySet<string>) => Promise<void>;
} }
@@ -135,6 +139,7 @@ export const createUpdateCenterService = (
): UpdateCenterService => { ): UpdateCenterService => {
const queue = createUpdateCenterQueue(); const queue = createUpdateCenterQueue();
const listeners = new Set<(snapshot: UpdateCenterServiceState) => void>(); const listeners = new Set<(snapshot: UpdateCenterServiceState) => void>();
let currentStoreFilter: StoreFilter = "both";
const loadIgnored = const loadIgnored =
options.loadIgnoredEntries ?? options.loadIgnoredEntries ??
(() => loadIgnoredEntries(IGNORE_CONFIG_PATH)); (() => loadIgnoredEntries(IGNORE_CONFIG_PATH));
@@ -157,13 +162,18 @@ export const createUpdateCenterService = (
return snapshot; return snapshot;
}; };
const refresh = async (): Promise<UpdateCenterServiceState> => { const refresh = async (
storeFilter: StoreFilter = currentStoreFilter,
): Promise<UpdateCenterServiceState> => {
currentStoreFilter = storeFilter;
queue.startRefresh(); queue.startRefresh();
emit(); emit();
try { try {
const ignoredEntries = await loadIgnored(); const ignoredEntries = await loadIgnored();
const loadedItems = normalizeLoadedItems(await options.loadItems()); const loadedItems = normalizeLoadedItems(
await options.loadItems(currentStoreFilter),
);
const items = sortIgnoredItems( const items = sortIgnoredItems(
applyIgnoredEntries(loadedItems.items, ignoredEntries), applyIgnoredEntries(loadedItems.items, ignoredEntries),
); );
+6 -4
View File
@@ -1,5 +1,7 @@
import { ipcRenderer, contextBridge, type IpcRendererEvent } from "electron"; import { ipcRenderer, contextBridge, type IpcRendererEvent } from "electron";
type StoreFilter = "spark" | "apm" | "both";
type UpdateCenterSnapshot = { type UpdateCenterSnapshot = {
items: Array<{ items: Array<{
taskKey: string; taskKey: string;
@@ -90,10 +92,10 @@ contextBridge.exposeInMainWorld("apm_store", {
}); });
contextBridge.exposeInMainWorld("updateCenter", { contextBridge.exposeInMainWorld("updateCenter", {
open: (): Promise<UpdateCenterSnapshot> => open: (storeFilter: StoreFilter = "both"): Promise<UpdateCenterSnapshot> =>
ipcRenderer.invoke("update-center-open"), ipcRenderer.invoke("update-center-open", storeFilter),
refresh: (): Promise<UpdateCenterSnapshot> => refresh: (storeFilter: StoreFilter = "both"): Promise<UpdateCenterSnapshot> =>
ipcRenderer.invoke("update-center-refresh"), ipcRenderer.invoke("update-center-refresh", storeFilter),
ignore: (payload: { ignore: (payload: {
packageName: string; packageName: string;
newVersion: string; newVersion: string;
+69 -7
View File
@@ -20,6 +20,7 @@
:active-category="activeCategory" :active-category="activeCategory"
:category-counts="categoryCounts" :category-counts="categoryCounts"
:theme-mode="themeMode" :theme-mode="themeMode"
:spark-available="sparkAvailable"
:apm-available="apmAvailable" :apm-available="apmAvailable"
:store-filter="storeFilter" :store-filter="storeFilter"
@toggle-theme="toggleTheme" @toggle-theme="toggleTheme"
@@ -120,6 +121,7 @@
:error="installedError" :error="installedError"
:active-origin="activeInstalledOrigin" :active-origin="activeInstalledOrigin"
:store-filter="storeFilter" :store-filter="storeFilter"
:spark-available="sparkAvailable"
:apm-available="apmAvailable" :apm-available="apmAvailable"
@close="closeInstalledModal" @close="closeInstalledModal"
@refresh="refreshInstalledApps" @refresh="refreshInstalledApps"
@@ -192,6 +194,12 @@ import {
rankAppsBySearch, rankAppsBySearch,
} from "./modules/appSearch"; } from "./modules/appSearch";
import { handleInstall, handleRetry } from "./modules/processInstall"; import { handleInstall, handleRetry } from "./modules/processInstall";
import {
getAllowedInstalledOrigin,
getEffectiveStoreFilter,
getDefaultInstalledOrigin,
isOriginEnabled,
} from "./modules/storeFilter";
import { createUpdateCenterStore } from "./modules/updateCenter"; import { createUpdateCenterStore } from "./modules/updateCenter";
import type { import type {
App, App,
@@ -264,10 +272,18 @@ const showUninstallModal = ref(false);
const uninstallTargetApp: Ref<App | null> = ref(null); const uninstallTargetApp: Ref<App | null> = ref(null);
const showAboutModal = ref(false); const showAboutModal = ref(false);
const showSettingsModal = ref(false); const showSettingsModal = ref(false);
const sparkAvailable = ref(false);
const apmAvailable = ref(false); const apmAvailable = ref(false);
/** 启动参数 --no-apm => 仅 Spark--no-spark => 仅 APM;由主进程 IPC 提供 */ /** 启动参数 --no-apm => 仅 Spark--no-spark => 仅 APM;由主进程 IPC 提供 */
const storeFilter = ref<"spark" | "apm" | "both">("both"); const storeFilter = ref<"spark" | "apm" | "both">("both");
const availableSources = computed(() => ({
spark: sparkAvailable.value,
apm: apmAvailable.value,
}));
const effectiveStoreFilter = computed(() =>
getEffectiveStoreFilter(storeFilter.value, availableSources.value),
);
// 计算属性 // 计算属性
const baseApps = computed(() => { const baseApps = computed(() => {
@@ -761,7 +777,11 @@ const handleList = () => {
const openUpdateModal = async () => { const openUpdateModal = async () => {
try { try {
await updateCenterStore.open(); if (!effectiveStoreFilter.value) {
return;
}
await updateCenterStore.open(effectiveStoreFilter.value);
} catch (error) { } catch (error) {
logger.error(`打开更新中心失败: ${error}`); logger.error(`打开更新中心失败: ${error}`);
} }
@@ -791,11 +811,21 @@ const confirmMigrationStart = async () => {
}; };
const openInstalledModal = () => { const openInstalledModal = () => {
showInstalledModal.value = true; const defaultOrigin = getDefaultInstalledOrigin(
// 如果没有 APM 可用,默认切换到 Spark 应用管理 storeFilter.value,
if (!apmAvailable.value && activeInstalledOrigin.value === "apm") { availableSources.value,
activeInstalledOrigin.value = "spark"; );
if (!defaultOrigin) {
return;
} }
showInstalledModal.value = true;
activeInstalledOrigin.value =
getAllowedInstalledOrigin(
storeFilter.value,
activeInstalledOrigin.value,
availableSources.value,
) ?? defaultOrigin;
refreshInstalledApps(); refreshInstalledApps();
}; };
@@ -804,7 +834,12 @@ const closeInstalledModal = () => {
}; };
const handleSwitchOrigin = (origin: "apm" | "spark") => { const handleSwitchOrigin = (origin: "apm" | "spark") => {
activeInstalledOrigin.value = origin; activeInstalledOrigin.value =
getAllowedInstalledOrigin(
storeFilter.value,
origin,
availableSources.value,
) ?? activeInstalledOrigin.value;
refreshInstalledApps(); refreshInstalledApps();
}; };
@@ -812,7 +847,24 @@ const refreshInstalledApps = async () => {
installedLoading.value = true; installedLoading.value = true;
installedError.value = ""; installedError.value = "";
try { try {
const origin = activeInstalledOrigin.value; const origin = getAllowedInstalledOrigin(
storeFilter.value,
activeInstalledOrigin.value,
availableSources.value,
);
if (!origin) {
installedApps.value = [];
installedError.value = "当前系统不可用应用管理功能";
return;
}
activeInstalledOrigin.value = origin;
if (!isOriginEnabled(storeFilter.value, origin)) {
installedApps.value = [];
installedError.value = `当前启动模式已禁用 ${origin === "spark" ? "Spark" : "APM"} 软件管理`;
return;
}
// Spark 优化:只检查远端商店目录中的应用,避免全量扫描 // Spark 优化:只检查远端商店目录中的应用,避免全量扫描
let pkgnameList: string[] | undefined; let pkgnameList: string[] | undefined;
@@ -1151,11 +1203,21 @@ onMounted(async () => {
// 从主进程获取启动参数(--no-apm / --no-spark),再加载数据 // 从主进程获取启动参数(--no-apm / --no-spark),再加载数据
storeFilter.value = await window.ipcRenderer.invoke("get-store-filter"); storeFilter.value = await window.ipcRenderer.invoke("get-store-filter");
if (storeFilter.value !== "apm") {
sparkAvailable.value = await window.ipcRenderer.invoke(
"check-spark-available",
);
}
// 检查 apm 是否可用 // 检查 apm 是否可用
if (storeFilter.value !== "spark") { if (storeFilter.value !== "spark") {
apmAvailable.value = await window.ipcRenderer.invoke("check-apm-available"); apmAvailable.value = await window.ipcRenderer.invoke("check-apm-available");
} }
activeInstalledOrigin.value =
getDefaultInstalledOrigin(storeFilter.value, availableSources.value) ??
"spark";
await loadCategories(); await loadCategories();
// 分类目录加载后,并行加载主页数据和所有应用列表 // 分类目录加载后,并行加载主页数据和所有应用列表
+37
View File
@@ -0,0 +1,37 @@
import { render, screen } from "@testing-library/vue";
import { describe, expect, it } from "vitest";
import AppSidebar from "@/components/AppSidebar.vue";
const renderSidebar = (
overrides: Partial<InstanceType<typeof AppSidebar>["$props"]> = {},
) => {
return render(AppSidebar, {
props: {
categories: {},
activeCategory: "all",
categoryCounts: { all: 0 },
themeMode: "auto",
storeFilter: "both",
sparkAvailable: true,
apmAvailable: true,
...overrides,
},
});
};
describe("AppSidebar", () => {
it("shows management and update entries when at least one source is usable", () => {
renderSidebar({ sparkAvailable: true, apmAvailable: false });
expect(screen.getByText("应用管理")).toBeTruthy();
expect(screen.getByText("软件更新")).toBeTruthy();
});
it("hides management and update entries when both sources are unavailable", () => {
renderSidebar({ sparkAvailable: false, apmAvailable: false });
expect(screen.queryByText("应用管理")).toBeNull();
expect(screen.queryByText("软件更新")).toBeNull();
});
});
@@ -35,6 +35,7 @@ describe("InstalledAppsModal", () => {
error: "", error: "",
activeOrigin: "spark", activeOrigin: "spark",
storeFilter: "both", storeFilter: "both",
sparkAvailable: true,
apmAvailable: true, apmAvailable: true,
}, },
}); });
@@ -54,6 +55,7 @@ describe("InstalledAppsModal", () => {
error: "", error: "",
activeOrigin: "spark", activeOrigin: "spark",
storeFilter: "both", storeFilter: "both",
sparkAvailable: true,
apmAvailable: true, apmAvailable: true,
}, },
}); });
@@ -71,6 +73,7 @@ describe("InstalledAppsModal", () => {
error: "", error: "",
activeOrigin: "spark", activeOrigin: "spark",
storeFilter: "both", storeFilter: "both",
sparkAvailable: true,
apmAvailable: true, apmAvailable: true,
}, },
}); });
@@ -92,6 +95,7 @@ describe("InstalledAppsModal", () => {
error: "", error: "",
activeOrigin: "spark", activeOrigin: "spark",
storeFilter: "both", storeFilter: "both",
sparkAvailable: true,
apmAvailable: true, apmAvailable: true,
}, },
}); });
@@ -113,6 +117,7 @@ describe("InstalledAppsModal", () => {
error: "", error: "",
activeOrigin: "spark", activeOrigin: "spark",
storeFilter: "both", storeFilter: "both",
sparkAvailable: true,
apmAvailable: true, apmAvailable: true,
}, },
}); });
@@ -129,6 +134,7 @@ describe("InstalledAppsModal", () => {
error: "", error: "",
activeOrigin: "spark", activeOrigin: "spark",
storeFilter: "both", storeFilter: "both",
sparkAvailable: true,
apmAvailable: true, apmAvailable: true,
}, },
}); });
+80
View File
@@ -0,0 +1,80 @@
import { describe, expect, it } from "vitest";
import {
getEffectiveStoreFilter,
getAllowedInstalledOrigin,
getDefaultInstalledOrigin,
isOriginEnabled,
isOriginUsable,
} from "@/modules/storeFilter";
describe("storeFilter helpers", () => {
it("reports whether an origin is enabled by the current store filter", () => {
expect(isOriginEnabled("both", "spark")).toBe(true);
expect(isOriginEnabled("both", "apm")).toBe(true);
expect(isOriginEnabled("spark", "spark")).toBe(true);
expect(isOriginEnabled("spark", "apm")).toBe(false);
expect(isOriginEnabled("apm", "apm")).toBe(true);
expect(isOriginEnabled("apm", "spark")).toBe(false);
});
it("chooses the default installed origin from the active store filter", () => {
expect(getDefaultInstalledOrigin("spark", { spark: true, apm: true })).toBe(
"spark",
);
expect(getDefaultInstalledOrigin("apm", { spark: true, apm: true })).toBe(
"apm",
);
expect(getDefaultInstalledOrigin("both", { spark: true, apm: true })).toBe(
"apm",
);
expect(getDefaultInstalledOrigin("both", { spark: true, apm: false })).toBe(
"spark",
);
expect(
getDefaultInstalledOrigin("both", { spark: false, apm: false }),
).toBe(null);
});
it("redirects disallowed installed origins to an allowed one", () => {
expect(
getAllowedInstalledOrigin("spark", "apm", { spark: true, apm: true }),
).toBe("spark");
expect(
getAllowedInstalledOrigin("apm", "spark", { spark: true, apm: true }),
).toBe("apm");
expect(
getAllowedInstalledOrigin("both", "apm", { spark: true, apm: false }),
).toBe("spark");
expect(
getAllowedInstalledOrigin("both", "spark", { spark: false, apm: false }),
).toBeNull();
});
it("computes the effective runtime store filter from source availability", () => {
expect(getEffectiveStoreFilter("both", { spark: true, apm: true })).toBe(
"both",
);
expect(getEffectiveStoreFilter("both", { spark: true, apm: false })).toBe(
"spark",
);
expect(getEffectiveStoreFilter("both", { spark: false, apm: true })).toBe(
"apm",
);
expect(getEffectiveStoreFilter("both", { spark: false, apm: false })).toBe(
null,
);
});
it("only treats enabled and installed origins as usable", () => {
expect(isOriginUsable("both", "spark", { spark: true, apm: false })).toBe(
true,
);
expect(isOriginUsable("both", "apm", { spark: true, apm: false })).toBe(
false,
);
expect(isOriginUsable("spark", "apm", { spark: true, apm: true })).toBe(
false,
);
});
});
@@ -25,6 +25,9 @@ const APTSS_WEATHER_PRINT_URIS_KEY =
const APTSS_NOTES_PRINT_URIS_KEY = 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"; "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 WHICH_APTSS_KEY = "which aptss";
const WHICH_APM_KEY = "which apm";
const loadUpdateCenterModule = async ( const loadUpdateCenterModule = async (
remoteStore: Record<string, RemoteStoreResponse>, remoteStore: Record<string, RemoteStoreResponse>,
) => { ) => {
@@ -106,6 +109,22 @@ afterEach(() => {
describe("update-center load items", () => { describe("update-center load items", () => {
it("enriches apm migration items with download metadata and remote fallback icons", async () => { it("enriches apm migration items with download metadata and remote fallback icons", async () => {
const commandResults = new Map<string, CommandResult>([ const commandResults = new Map<string, CommandResult>([
[
WHICH_APTSS_KEY,
{
code: 0,
stdout: "/usr/bin/aptss\n",
stderr: "",
},
],
[
WHICH_APM_KEY,
{
code: 0,
stdout: "/usr/bin/apm\n",
stderr: "",
},
],
[ [
APTSS_LIST_UPGRADABLE_KEY, APTSS_LIST_UPGRADABLE_KEY,
{ {
@@ -217,6 +236,14 @@ describe("update-center load items", () => {
const result = await loadUpdateCenterItems(async (command, args) => { const result = await loadUpdateCenterItems(async (command, args) => {
const key = `${command} ${args.join(" ")}`; const key = `${command} ${args.join(" ")}`;
if (key === WHICH_APTSS_KEY) {
return { code: 0, stdout: "/usr/bin/aptss\n", stderr: "" };
}
if (key === WHICH_APM_KEY) {
return { code: 127, stdout: "", stderr: "apm: command not found" };
}
if (key === APTSS_LIST_UPGRADABLE_KEY) { if (key === APTSS_LIST_UPGRADABLE_KEY) {
return { return {
code: 0, code: 0,
@@ -279,10 +306,7 @@ describe("update-center load items", () => {
sha512: "beadfeed", sha512: "beadfeed",
}, },
]); ]);
expect(result.warnings).toEqual([ expect(result.warnings).toEqual([]);
"apm upgradable query failed: apm: command not found",
"apm installed query failed: apm: command not found",
]);
}); });
it("retries category lookup after an earlier fetch failure in the same process", async () => { it("retries category lookup after an earlier fetch failure in the same process", async () => {
@@ -292,6 +316,14 @@ describe("update-center load items", () => {
const runCommand = async (command: string, args: string[]) => { const runCommand = async (command: string, args: string[]) => {
const key = `${command} ${args.join(" ")}`; const key = `${command} ${args.join(" ")}`;
if (key === WHICH_APTSS_KEY) {
return { code: 0, stdout: "/usr/bin/aptss\n", stderr: "" };
}
if (key === WHICH_APM_KEY) {
return { code: 127, stdout: "", stderr: "apm: command not found" };
}
if (key === APTSS_LIST_UPGRADABLE_KEY) { if (key === APTSS_LIST_UPGRADABLE_KEY) {
return { return {
code: 0, code: 0,
@@ -387,6 +419,14 @@ describe("update-center load items", () => {
const result = await loadUpdateCenterItems(async (command, args) => { const result = await loadUpdateCenterItems(async (command, args) => {
const key = `${command} ${args.join(" ")}`; const key = `${command} ${args.join(" ")}`;
if (key === WHICH_APTSS_KEY) {
return { code: 0, stdout: "/usr/bin/aptss\n", stderr: "" };
}
if (key === WHICH_APM_KEY) {
return { code: 127, stdout: "", stderr: "apm: command not found" };
}
if (key === APTSS_LIST_UPGRADABLE_KEY) { if (key === APTSS_LIST_UPGRADABLE_KEY) {
return { return {
code: 0, code: 0,
@@ -440,9 +480,122 @@ describe("update-center load items", () => {
sha512: "beadfeed", sha512: "beadfeed",
}, },
]); ]);
expect(result.warnings).toEqual([ expect(result.warnings).toEqual([]);
"apm upgradable query failed: apm: command not found", });
"apm installed query failed: apm: command not found",
it("skips aptss commands when the store filter disables Spark", async () => {
const { loadUpdateCenterItems } = await loadUpdateCenterModule({
"https://erotica.spark-app.store/amd64-apm/categories.json": {
tools: { zh: "Tools" },
},
"https://erotica.spark-app.store/amd64-apm/tools/applist.json": [
{ Name: "Spark Clock", Pkgname: "spark-clock" },
],
});
const runCommand = vi.fn(async (command: string, args: string[]) => {
const key = `${command} ${args.join(" ")}`;
if (key === WHICH_APM_KEY) {
return { code: 0, stdout: "/usr/bin/apm\n", stderr: "" };
}
if (key === "apm list --upgradable") {
return {
code: 0,
stdout: "spark-clock/main 2.0.0 amd64 [upgradable from: 1.0.0]",
stderr: "",
};
}
if (key === "apm list --installed") {
return {
code: 0,
stdout: "",
stderr: "",
};
}
if (
key ===
"bash -lc amber-pm-debug /usr/bin/apt -c /opt/durapps/spark-store/bin/apt-fast-conf/aptss-apt.conf download spark-clock --print-uris"
) {
return {
code: 0,
stdout:
"'https://example.invalid/spark-clock_2.0.0_amd64.deb' spark-clock_2.0.0_amd64.deb 1234 SHA512:feedface",
stderr: "",
};
}
throw new Error(`Unexpected command ${key}`);
});
await loadUpdateCenterItems(runCommand, "apm");
expect(runCommand).not.toHaveBeenCalledWith(
"bash",
expect.arrayContaining([
expect.stringContaining("apt list --upgradable"),
]),
);
expect(runCommand).not.toHaveBeenCalledWith(
"dpkg-query",
expect.any(Array),
);
});
it("skips apm commands when the store filter disables APM", async () => {
const { loadUpdateCenterItems } = await loadUpdateCenterModule({
"https://erotica.spark-app.store/amd64-store/categories.json": {
office: { zh: "Office" },
},
"https://erotica.spark-app.store/amd64-store/office/applist.json": [
{ Name: "Spark Notes", Pkgname: "spark-notes" },
],
});
const runCommand = vi.fn(async (command: string, args: string[]) => {
const key = `${command} ${args.join(" ")}`;
if (key === WHICH_APTSS_KEY) {
return { code: 0, stdout: "/usr/bin/aptss\n", stderr: "" };
}
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 === 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: "",
};
}
throw new Error(`Unexpected command ${key}`);
});
await loadUpdateCenterItems(runCommand, "spark");
expect(runCommand).not.toHaveBeenCalledWith("apm", [
"list",
"--upgradable",
]); ]);
expect(runCommand).not.toHaveBeenCalledWith("apm", ["list", "--installed"]);
}); });
}); });
@@ -157,8 +157,8 @@ describe("update-center/ipc", () => {
await cancelHandler?.({}, "aptss:spark-weather"); await cancelHandler?.({}, "aptss:spark-weather");
expect(getStateHandler?.()).toEqual(snapshot); expect(getStateHandler?.()).toEqual(snapshot);
expect(service.open).toHaveBeenCalledTimes(1); expect(service.open).toHaveBeenCalledWith("both");
expect(service.refresh).toHaveBeenCalledTimes(1); expect(service.refresh).toHaveBeenCalledWith("both");
expect(service.ignore).toHaveBeenCalledWith({ expect(service.ignore).toHaveBeenCalledWith({
packageName: "spark-weather", packageName: "spark-weather",
newVersion: "2.0.0", newVersion: "2.0.0",
@@ -176,6 +176,51 @@ describe("update-center/ipc", () => {
expect(send).toHaveBeenCalledWith("update-center-state", snapshot); expect(send).toHaveBeenCalledWith("update-center-state", snapshot);
}); });
it("forwards store filter payloads to open and refresh", async () => {
const handle = vi.fn();
const snapshot: UpdateCenterServiceState = {
items: [],
tasks: [],
warnings: [],
hasRunningTasks: false,
};
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(() => () => undefined),
};
registerUpdateCenterIpc({ handle }, service);
const openHandler = handle.mock.calls.find(
([channel]: [string]) => channel === "update-center-open",
)?.[1] as
| ((
event: unknown,
storeFilter?: "spark" | "apm" | "both",
) => Promise<UpdateCenterServiceState>)
| undefined;
const refreshHandler = handle.mock.calls.find(
([channel]: [string]) => channel === "update-center-refresh",
)?.[1] as
| ((
event: unknown,
storeFilter?: "spark" | "apm" | "both",
) => Promise<UpdateCenterServiceState>)
| undefined;
await openHandler?.({}, "apm");
await refreshHandler?.({}, "spark");
expect(service.open).toHaveBeenCalledWith("apm");
expect(service.refresh).toHaveBeenCalledWith("spark");
});
it("service subscribers receive state updates after refresh start and ignore", async () => { it("service subscribers receive state updates after refresh start and ignore", async () => {
let ignoredEntries = new Set<string>(); let ignoredEntries = new Set<string>();
const send = vi.fn(); const send = vi.fn();
@@ -61,9 +61,9 @@ describe("updateCenter store", () => {
open.mockResolvedValue(snapshot); open.mockResolvedValue(snapshot);
const store = createUpdateCenterStore(); const store = createUpdateCenterStore();
await store.open(); await store.open("apm");
expect(open).toHaveBeenCalledTimes(1); expect(open).toHaveBeenCalledWith("apm");
expect(store.isOpen.value).toBe(true); expect(store.isOpen.value).toBe(true);
expect(store.snapshot.value).toEqual(snapshot); expect(store.snapshot.value).toEqual(snapshot);
expect(store.filteredItems.value).toEqual(snapshot.items); expect(store.filteredItems.value).toEqual(snapshot.items);
+14 -2
View File
@@ -89,7 +89,7 @@
<div class="border-t border-slate-200 pt-4 dark:border-slate-800"> <div class="border-t border-slate-200 pt-4 dark:border-slate-800">
<button <button
v-if="storeFilter !== 'spark'" v-if="canManageApps"
type="button" type="button"
class="flex w-full items-center gap-3 rounded-2xl border border-transparent px-4 py-3 text-left text-sm font-medium text-slate-600 transition hover:border-brand/30 hover:bg-brand/5 hover:text-brand focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/40 dark:text-slate-300 dark:hover:bg-slate-800" class="flex w-full items-center gap-3 rounded-2xl border border-transparent px-4 py-3 text-left text-sm font-medium text-slate-600 transition hover:border-brand/30 hover:bg-brand/5 hover:text-brand focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/40 dark:text-slate-300 dark:hover:bg-slate-800"
@click="$emit('list')" @click="$emit('list')"
@@ -98,6 +98,7 @@
<span>应用管理</span> <span>应用管理</span>
</button> </button>
<button <button
v-if="canOpenUpdateCenter"
type="button" type="button"
class="flex w-full items-center gap-3 rounded-2xl border border-transparent px-4 py-3 text-left text-sm font-medium text-slate-600 transition hover:border-brand/30 hover:bg-brand/5 hover:text-brand focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/40 dark:text-slate-300 dark:hover:bg-slate-800" class="flex w-full items-center gap-3 rounded-2xl border border-transparent px-4 py-3 text-left text-sm font-medium text-slate-600 transition hover:border-brand/30 hover:bg-brand/5 hover:text-brand focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/40 dark:text-slate-300 dark:hover:bg-slate-800"
@click="$emit('update')" @click="$emit('update')"
@@ -110,15 +111,17 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from "vue";
import ThemeToggle from "./ThemeToggle.vue"; import ThemeToggle from "./ThemeToggle.vue";
import amberLogo from "../assets/imgs/spark-store.svg"; import amberLogo from "../assets/imgs/spark-store.svg";
defineProps<{ const props = defineProps<{
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
categories: Record<string, any>; categories: Record<string, any>;
activeCategory: string; activeCategory: string;
categoryCounts: Record<string, number>; categoryCounts: Record<string, number>;
themeMode: "light" | "dark" | "auto"; themeMode: "light" | "dark" | "auto";
sparkAvailable: boolean;
apmAvailable: boolean; apmAvailable: boolean;
storeFilter: "spark" | "apm" | "both"; storeFilter: "spark" | "apm" | "both";
}>(); }>();
@@ -135,6 +138,15 @@ const toggleTheme = () => {
emit("toggle-theme"); emit("toggle-theme");
}; };
const canManageApps = computed(() => {
return (
(props.storeFilter !== "apm" && props.sparkAvailable) ||
(props.storeFilter !== "spark" && props.apmAvailable)
);
});
const canOpenUpdateCenter = canManageApps;
const selectCategory = (category: string) => { const selectCategory = (category: string) => {
emit("select-category", category); emit("select-category", category);
}; };
+18 -3
View File
@@ -29,10 +29,11 @@
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div <div
v-if="storeFilter === 'both'" v-if="showOriginSwitcher"
class="flex items-center rounded-2xl border border-slate-200/70 p-1 dark:border-slate-800/70" class="flex items-center rounded-2xl border border-slate-200/70 p-1 dark:border-slate-800/70"
> >
<button <button
v-if="apmEnabled"
type="button" type="button"
class="rounded-xl px-4 py-1.5 text-sm font-semibold transition" class="rounded-xl px-4 py-1.5 text-sm font-semibold transition"
:class=" :class="
@@ -46,6 +47,7 @@
APM 软件 APM 软件
</button> </button>
<button <button
v-if="sparkEnabled"
type="button" type="button"
class="rounded-xl px-4 py-1.5 text-sm font-semibold transition" class="rounded-xl px-4 py-1.5 text-sm font-semibold transition"
:class=" :class="
@@ -185,7 +187,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { reactive } from "vue"; import { computed, reactive } from "vue";
import { App } from "../global/typedefinition"; import { App } from "../global/typedefinition";
import { APM_STORE_BASE_URL } from "../global/storeConfig"; import { APM_STORE_BASE_URL } from "../global/storeConfig";
@@ -209,13 +211,14 @@ const canOpenDetail = (app: App) => {
); );
}; };
defineProps<{ const props = defineProps<{
show: boolean; show: boolean;
apps: App[]; apps: App[];
loading: boolean; loading: boolean;
error: string; error: string;
activeOrigin: "apm" | "spark"; activeOrigin: "apm" | "spark";
storeFilter: "spark" | "apm" | "both"; storeFilter: "spark" | "apm" | "both";
sparkAvailable: boolean;
apmAvailable: boolean; apmAvailable: boolean;
}>(); }>();
@@ -233,4 +236,16 @@ const onOverlayWheel = (e: WheelEvent) => {
if (target.closest(".overflow-y-auto, .overflow-auto")) return; if (target.closest(".overflow-y-auto, .overflow-auto")) return;
e.preventDefault(); e.preventDefault();
}; };
const sparkEnabled = computed(() => {
return props.storeFilter !== "apm" && props.sparkAvailable;
});
const apmEnabled = computed(() => {
return props.storeFilter !== "spark" && props.apmAvailable;
});
const showOriginSwitcher = computed(() => {
return sparkEnabled.value && apmEnabled.value;
});
</script> </script>
+4 -2
View File
@@ -26,6 +26,8 @@ export type DownloadItemStatus =
export type StoreMode = "spark" | "apm" | "hybrid"; export type StoreMode = "spark" | "apm" | "hybrid";
export type StoreFilter = "spark" | "apm" | "both";
export interface DownloadItem { export interface DownloadItem {
id: number; id: number;
name: string; name: string;
@@ -178,8 +180,8 @@ export interface UpdateCenterSnapshot {
} }
export interface UpdateCenterBridge { export interface UpdateCenterBridge {
open: () => Promise<UpdateCenterSnapshot>; open: (storeFilter?: StoreFilter) => Promise<UpdateCenterSnapshot>;
refresh: () => Promise<UpdateCenterSnapshot>; refresh: (storeFilter?: StoreFilter) => Promise<UpdateCenterSnapshot>;
ignore: (payload: { ignore: (payload: {
packageName: string; packageName: string;
newVersion: string; newVersion: string;
+83
View File
@@ -0,0 +1,83 @@
import type { StoreFilter } from "@/global/typedefinition";
export interface SourceAvailability {
spark: boolean;
apm: boolean;
}
export const isOriginEnabled = (
storeFilter: StoreFilter,
origin: "spark" | "apm",
): boolean => {
return storeFilter === "both" || storeFilter === origin;
};
export const getDefaultInstalledOrigin = (
storeFilter: StoreFilter,
availability: SourceAvailability,
): "spark" | "apm" | null => {
if (storeFilter === "spark") {
return availability.spark ? "spark" : null;
}
if (storeFilter === "apm") {
return availability.apm ? "apm" : null;
}
if (availability.apm) {
return "apm";
}
if (availability.spark) {
return "spark";
}
return null;
};
export const getEffectiveStoreFilter = (
storeFilter: StoreFilter,
availability: SourceAvailability,
): StoreFilter | null => {
if (storeFilter === "spark") {
return availability.spark ? "spark" : null;
}
if (storeFilter === "apm") {
return availability.apm ? "apm" : null;
}
if (availability.spark && availability.apm) {
return "both";
}
if (availability.spark) {
return "spark";
}
if (availability.apm) {
return "apm";
}
return null;
};
export const isOriginUsable = (
storeFilter: StoreFilter,
origin: "spark" | "apm",
availability: SourceAvailability,
): boolean => {
return isOriginEnabled(storeFilter, origin) && availability[origin];
};
export const getAllowedInstalledOrigin = (
storeFilter: StoreFilter,
requestedOrigin: "spark" | "apm",
availability: SourceAvailability,
): "spark" | "apm" | null => {
if (isOriginUsable(storeFilter, requestedOrigin, availability)) {
return requestedOrigin;
}
return getDefaultInstalledOrigin(storeFilter, availability);
};
+7 -6
View File
@@ -5,6 +5,7 @@ import type {
UpdateCenterSnapshot, UpdateCenterSnapshot,
DownloadItem, DownloadItem,
UpdateCenterStartTask, UpdateCenterStartTask,
StoreFilter,
} from "@/global/typedefinition"; } from "@/global/typedefinition";
import { downloads, getNextUpdateDownloadId } from "@/global/downloadStatus"; import { downloads, getNextUpdateDownloadId } from "@/global/downloadStatus";
import { APM_STORE_BASE_URL } from "@/global/storeConfig"; import { APM_STORE_BASE_URL } from "@/global/storeConfig";
@@ -28,8 +29,8 @@ export interface UpdateCenterStore {
someSelected: ComputedRef<boolean>; someSelected: ComputedRef<boolean>;
bind: () => void; bind: () => void;
unbind: () => void; unbind: () => void;
open: () => Promise<void>; open: (storeFilter?: StoreFilter) => Promise<void>;
refresh: () => Promise<void>; refresh: (storeFilter?: StoreFilter) => Promise<void>;
ignoreItem: (packageName: string, newVersion: string) => Promise<void>; ignoreItem: (packageName: string, newVersion: string) => Promise<void>;
unignoreItem: (packageName: string, newVersion: string) => Promise<void>; unignoreItem: (packageName: string, newVersion: string) => Promise<void>;
toggleSelection: (taskKey: string) => void; toggleSelection: (taskKey: string) => void;
@@ -129,15 +130,15 @@ export const createUpdateCenterStore = (): UpdateCenterStore => {
isBound = false; isBound = false;
}; };
const open = async (): Promise<void> => { const open = async (storeFilter: StoreFilter = "both"): Promise<void> => {
resetSessionState(); resetSessionState();
const nextSnapshot = await window.updateCenter.open(); const nextSnapshot = await window.updateCenter.open(storeFilter);
applySnapshot(nextSnapshot); applySnapshot(nextSnapshot);
isOpen.value = true; isOpen.value = true;
}; };
const refresh = async (): Promise<void> => { const refresh = async (storeFilter: StoreFilter = "both"): Promise<void> => {
const nextSnapshot = await window.updateCenter.refresh(); const nextSnapshot = await window.updateCenter.refresh(storeFilter);
applySnapshot(nextSnapshot); applySnapshot(nextSnapshot);
}; };