feat(update-center): 支持被系统锁定(hold)包的强制安装与更新中心体验优化

- 标记 apt-mark hold 的 spark(aptss) 源包,默认禁用勾选并提示开启强制安装
- 更新中心发起更新时透传 forceHeld,后端 Spark 本地 deb 走 shell-caller
  force-ssinstall 分支(单次免密 pkexec:unhold→ssinstall→hold),避免二次提权
- 无可选项时禁用全选并提示"无可更新项";更新选中后从列表中移除已启动项
- DownloadDetail 紧凑布局避免整页滚动;Toolbar 全选与更新选中按钮同行
- 继承 PKGNAME_PATTERN 与 metalinkUrl 白名单(https+*.spark-app.store+拒..)纵深防御
- apm 源无 hold 概念,forceHeld 在 apm 分支忽略(正确行为)
This commit is contained in:
xiyidaiwa
2026-08-13 16:42:21 +08:00
parent b5c5ab04b5
commit 0f62a7e36a
17 changed files with 893 additions and 105 deletions
+11 -2
View File
@@ -219,9 +219,15 @@ window.ipcRenderer.on("install-log", (_event, log: InstallLog) => {
window.ipcRenderer.on("install-complete", (_event, log: DownloadResult) => {
const downloadObj = downloads.value.find((d) => d.id === log.id);
if (downloadObj) {
// 更新中心来源的任务用「更新」语义,其余(普通安装)用「安装」语义,
// 避免把安装结果误报为「下载完成」。
const isUpdate = (downloadObj.source ?? "").toLowerCase().includes("update");
const doneLabel = isUpdate ? "更新完成" : "安装完成";
const failVerb = isUpdate ? "更新失败" : "安装失败";
if (log.success) {
downloadObj.status = "completed";
downloadObj.logs.push({ time: Date.now(), message: "下载完成" });
downloadObj.logs.push({ time: Date.now(), message: doneLabel });
} else {
downloadObj.status = "failed";
// 将失败原因写入日志,避免 UI 停在"正在获取 Metalink"等中间日志后突兀结束
@@ -232,7 +238,10 @@ window.ipcRenderer.on("install-complete", (_event, log: DownloadResult) => {
} catch {
reason = log.message || reason;
}
downloadObj.logs.push({ time: Date.now(), message: `下载失败: ${reason}` });
downloadObj.logs.push({
time: Date.now(),
message: `${failVerb}: ${reason}`,
});
}
}
});
+57 -9
View File
@@ -24,10 +24,12 @@ export interface UpdateCenterStore {
showMigrationConfirm: Ref<boolean>;
searchQuery: Ref<string>;
selectedTaskKeys: Ref<Set<string>>;
forcedTaskKeys: Ref<Set<string>>;
snapshot: Ref<UpdateCenterSnapshot>;
filteredItems: ComputedRef<UpdateCenterItem[]>;
allSelected: ComputedRef<boolean>;
someSelected: ComputedRef<boolean>;
selectableCount: ComputedRef<number>;
bind: () => void;
unbind: () => void;
open: (storeFilter?: StoreFilter) => Promise<void>;
@@ -35,6 +37,7 @@ export interface UpdateCenterStore {
ignoreItem: (packageName: string, newVersion: string) => Promise<void>;
unignoreItem: (packageName: string, newVersion: string) => Promise<void>;
toggleSelection: (taskKey: string) => void;
toggleForce: (taskKey: string) => void;
toggleSelectAll: () => void;
getSelectedItems: () => UpdateCenterItem[];
closeNow: () => void;
@@ -60,21 +63,32 @@ export const createUpdateCenterStore = (): UpdateCenterStore => {
const showMigrationConfirm = ref(false);
const searchQuery = ref("");
const selectedTaskKeys = ref(new Set<string>());
// 强制安装集合:仅当前会话内有效(刷新/重开更新中心即重置),不持久化。
// 仅对被系统锁定(held)的项有意义:开启后该 held 项可被单独选中并升级。
const forcedTaskKeys = ref(new Set<string>());
const snapshot = ref<UpdateCenterSnapshot>(EMPTY_SNAPSHOT);
let lastStoreFilter: StoreFilter = "both";
// 判断某 item 是否可被选中:未被忽略,且(未被锁定 或 已开启强制安装)
const isSelectable = (item: UpdateCenterItem): boolean => {
if (item.ignored === true) return false;
if (item.held === true && !forcedTaskKeys.value.has(item.taskKey)) {
return false;
}
return true;
};
const resetSessionState = (): void => {
showCloseConfirm.value = false;
showMigrationConfirm.value = false;
searchQuery.value = "";
selectedTaskKeys.value = new Set();
forcedTaskKeys.value = new Set();
};
const applySnapshot = (nextSnapshot: UpdateCenterSnapshot): void => {
const selectableTaskKeys = new Set(
nextSnapshot.items
.filter((item) => item.ignored !== true)
.map((item) => item.taskKey),
nextSnapshot.items.filter(isSelectable).map((item) => item.taskKey),
);
selectedTaskKeys.value = new Set(
[...selectedTaskKeys.value].filter((taskKey) =>
@@ -85,7 +99,7 @@ export const createUpdateCenterStore = (): UpdateCenterStore => {
};
const selectableItems = computed(() =>
snapshot.value.items.filter((item) => item.ignored !== true),
snapshot.value.items.filter(isSelectable),
);
const filteredItems = computed(() => {
@@ -93,10 +107,11 @@ export const createUpdateCenterStore = (): UpdateCenterStore => {
const matched = snapshot.value.items.filter((item) =>
matchesSearch(item, query),
);
// 已忽略项沉底:非忽略在前、已忽略在后,各自保持原有顺序
// 已忽略项沉底:可选(含已强制的锁定项)在前、不可选(已忽略 / 未强制的锁定项)在后,
// 各自保持原有顺序
return [
...matched.filter((item) => item.ignored !== true),
...matched.filter((item) => item.ignored === true),
...matched.filter(isSelectable),
...matched.filter((item) => !isSelectable(item)),
];
});
@@ -116,6 +131,9 @@ export const createUpdateCenterStore = (): UpdateCenterStore => {
);
});
// 可选项数量:被忽略 / 被锁定未强制的项不计入。用于全选按钮可用性判断。
const selectableCount = computed(() => selectableItems.value.length);
const handleState = (nextSnapshot: UpdateCenterSnapshot): void => {
applySnapshot(nextSnapshot);
};
@@ -240,7 +258,7 @@ export const createUpdateCenterStore = (): UpdateCenterStore => {
const item = snapshot.value.items.find(
(entry) => entry.taskKey === taskKey,
);
if (!item || item.ignored === true) {
if (!item || !isSelectable(item)) {
return;
}
@@ -254,6 +272,32 @@ export const createUpdateCenterStore = (): UpdateCenterStore => {
selectedTaskKeys.value = nextSelection;
};
// 切换「强制安装」开关(仅对被系统锁定的包有意义)。
// 关闭强制时,若该包已被选中则同步取消其选中态。
const toggleForce = (taskKey: string): void => {
const item = snapshot.value.items.find(
(entry) => entry.taskKey === taskKey,
);
if (!item || item.held !== true) {
return;
}
const nextForced = new Set(forcedTaskKeys.value);
if (nextForced.has(taskKey)) {
nextForced.delete(taskKey);
// 取消强制后该包不可再被选中,移出选中集
if (selectedTaskKeys.value.has(taskKey)) {
const nextSelection = new Set(selectedTaskKeys.value);
nextSelection.delete(taskKey);
selectedTaskKeys.value = nextSelection;
}
} else {
nextForced.add(taskKey);
}
forcedTaskKeys.value = nextForced;
};
const toggleSelectAll = (): void => {
const selectable = selectableItems.value;
if (allSelected.value) {
@@ -266,7 +310,7 @@ export const createUpdateCenterStore = (): UpdateCenterStore => {
const getSelectedItems = (): UpdateCenterItem[] => {
return snapshot.value.items.filter(
(item) =>
selectedTaskKeys.value.has(item.taskKey) && item.ignored !== true,
selectedTaskKeys.value.has(item.taskKey) && isSelectable(item),
);
};
@@ -328,6 +372,7 @@ export const createUpdateCenterStore = (): UpdateCenterStore => {
startTasks.push({
taskKey: item.taskKey,
id: downloadId,
forceHeld: item.held === true,
});
}
});
@@ -351,10 +396,12 @@ export const createUpdateCenterStore = (): UpdateCenterStore => {
showMigrationConfirm,
searchQuery,
selectedTaskKeys,
forcedTaskKeys,
snapshot,
filteredItems,
allSelected,
someSelected,
selectableCount,
bind,
unbind,
open,
@@ -362,6 +409,7 @@ export const createUpdateCenterStore = (): UpdateCenterStore => {
ignoreItem,
unignoreItem,
toggleSelection,
toggleForce,
toggleSelectAll,
getSelectedItems,
closeNow,