Compare commits

...

34 Commits

Author SHA1 Message Date
shenmo7192 f62665cd73 release: bump version to 5.1.1 and fix dns download issue
add --async-dns=false aria2 parameter to all download jobs to fix potential dns resolution failures during package download
2026-05-16 02:35:29 +08:00
shenmo7192 e16acbd0a5 refactor(installer): 调整下载重试超时配置和次数
更新了下载重试的超时时间列表和总重试次数,从原3次调整为10次,优化下载成功率
2026-05-13 21:13:25 +08:00
shenmo7192 8a5f8d154f feat: 添加APM安装确认弹窗并重构APM检查流程
1. 新增全局状态控制APM安装弹窗显示
2. 新建ApmInstallConfirmModal弹窗组件
3. 将主进程的APM安装弹窗逻辑迁移到前端Vue组件
4. 更新package.json版本到5.1.0
5. 简化安装和升级流程中的APM检查逻辑
2026-05-12 21:54:47 +08:00
shenmo7192 8c8b53fc29 update tool/apt-fast/ss-apt-fast.
Signed-off-by: shenmo <jifengshenmo@outlook.com>
2026-05-09 15:04:46 +00:00
shenmo7192 c50655c106 !389 dark下的效果还是怪怪的重新改一下,还有统一应用管理和软件更新的按钮样式
Merge pull request !389 from zeqi/Erotica
2026-05-03 04:02:34 +00:00
zeqi 4b37aa4da4 dark下的效果还是怪怪的重新改一下,还有统一应用管理和软件更新的按钮样式
Signed-off-by: zeqi <a202128502@163.com>
2026-05-03 02:35:28 +00:00
shenmo7192 ce5de692f7 修复 Ubuntu 26.04 上无法正常安装的问题
Signed-off-by: shenmo <jifengshenmo@outlook.com>
2026-04-30 15:29:13 +00:00
shenmo7192 3d4af0c492 改为5.0.1 2026-04-25 14:40:37 +08:00
shenmo7192 c39b25e393 5.1 2026-04-25 14:33:21 +08:00
shenmo7192 2086152aa5 refactor: 移除缓存清除函数并直接使用原始URL
移除cacheBuster函数及其所有调用,改为直接使用原始URL进行请求
2026-04-25 14:25:52 +08:00
shenmo7192 f8f112a782 !388 feat(build): add loong64 to build
Merge pull request !388 from AAA Elysia 猫猫侠 ⁧~喵/elysia/add-loong64
2026-04-21 14:04:55 +00:00
Elysia 6a9091b2ec feat(build): add loong64
- Downgrad electron for the sake of loong64
- Add my project to CREDIT.md

Signed-off-by: Elysia <a.elysia@proton.me>
2026-04-19 09:37:21 +08:00
shenmo7192 994dbaf9b9 !387 优化dark模式下应用管理的关闭按钮和刷新按钮、软件更新的关闭按钮的hover效果
Merge pull request !387 from zeqi/Erotica
2026-04-16 22:59:56 +00:00
zeqi 9c9f0b6076 优化dark模式下应用管理的关闭按钮和刷新按钮、软件更新的关闭按钮的hover效果
Signed-off-by: zeqi <a202128502@163.com>
2026-04-16 15:27:55 +00:00
shenmo7192 42046caf2c feat(update-center): 添加加载状态处理及UI优化
为更新中心添加加载状态管理,包括:
- 在打开和刷新操作时显示加载状态
- 禁用刷新按钮防止重复操作
- 添加加载中的动画效果和提示文本
- 优化加载时的UI显示
2026-04-16 14:00:33 +08:00
shenmo7192 e72553d570 feat(update-center): 添加详细日志记录以帮助调试更新中心服务
在更新中心服务的关键路径添加console.log和console.error输出
包括服务刷新、包解析、命令执行等环节的输入输出和中间状态
便于排查更新中心相关的问题
2026-04-16 13:48:08 +08:00
momen 309b9bc003 fix(update-center): load aptss updates reliably 2026-04-16 13:32:23 +08:00
momen 0b784af3d7 fix(sources): hide unavailable update and management entries 2026-04-16 13:04:54 +08:00
momen e1ec526cb9 fix(update-center): handle missing apm and restore scrolling 2026-04-16 11:11:06 +08:00
shenmo7192 120233cf56 feat(settings): 添加安装设置模态框及配置管理功能
实现安装设置功能,包括更新检测通知和自动创建桌面启动器的开关配置
重构原有的安装设置逻辑,使用模态框替代直接调用脚本
新增 IPC 接口用于获取和保存设置配置
2026-04-16 00:35:37 +08:00
shenmo7192 a2d4192592 chore: 更新版本号至5.0.0 2026-04-16 00:22:06 +08:00
shenmo7192 c907fbb5d4 refactor(AppDetailModal): 优化应用详情弹窗的布局和样式
将版本号和下载量合并显示,并调整应用来源切换的布局结构
2026-04-16 00:21:38 +08:00
shenmo7192 68dd6a0a26 perf(spark): 优化已安装应用检查逻辑
- 对于 Spark 应用,使用 dpkg-query 替代自定义脚本检查安装状态
- 在 list-installed 接口中支持传入包名列表进行批量检查,避免全量扫描
- 添加 aptss 可用性检查,避免在不可用时执行相关命令
- 移除冗余的 check-installed 二次验证步骤
2026-04-16 00:10:14 +08:00
shenmo7192 9eb141ee35 fix: 简化包安装检查逻辑并添加二次确认
移除复杂的ACE环境检查逻辑,仅保留基本的dpkg检查
在App.vue中添加二次确认步骤以确保包真实安装
2026-04-15 23:40:30 +08:00
momen f9aa31d257 fix(update-center): align modal actions and tests 2026-04-15 22:21:35 +08:00
momen 1410a80df5 fix(installed-apps): restore open and detail actions 2026-04-15 22:10:02 +08:00
momen fcdd982637 docs(update-center): add no-aptss handling design 2026-04-15 21:49:25 +08:00
momen bed2d43e0e fix(update): 聚合 Spark 和 APM 升级通知 2026-04-15 20:49:15 +08:00
momen 44587e299a fix(lint): 清理未使用的安装器符号 2026-04-15 14:11:46 +08:00
momen 36f5d3831e fix(update): 统一忽略更新配置到用户目录 2026-04-15 11:44:18 +08:00
momen 51664619f5 修复更新器打开卡顿,优化更新中心 2026-04-15 10:55:47 +08:00
xiyidaiwa bd8b50677e !385 !1 refactor(AppHeader): 优化搜索输入框交互及样式
Merge pull request !385 from xiyidaiwa/Erotica
2026-04-15 02:45:07 +00:00
shenmo7192 78c9679f88 !383 feat(搜索): 为搜索输入框添加清除按钮功能
Merge pull request !383 from xiyidaiwa/Erotica
2026-04-14 07:49:56 +00:00
xiyidaiwa c9c84e518b feat(搜索): 为搜索输入框添加清除按钮功能
在AppHeader和UpdateCenterToolbar组件中为搜索输入框添加清除按钮
点击按钮可清空搜索内容并触发相应事件
2026-04-14 14:07:23 +08:00
64 changed files with 17183 additions and 930 deletions
+20
View File
@@ -23,3 +23,23 @@
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. SOFTWARE.
2. https://github.com/elysia-best/apm-app-store MulanPSL-2.0
Copyright (c) 2026-present The Spark Project Contributors
apm-store is licensed under Mulan PSL v2.
You can use this software according to the terms and conditions of the Mulan PSL v2.
You may obtain a copy of Mulan PSL v2 at:
http://license.coscl.org.cn/MulanPSL2
THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND,
EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,
MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
See the Mulan PSL v2 for more details.
@@ -0,0 +1,996 @@
# Gitee Issue Bot Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build a user-level `systemd`-driven issue bot that checks Spark Store Gitee issues every 6 hours, stores one ranked candidate locally, and only launches a new opencode window after explicit manual approval.
**Architecture:** Keep the implementation outside the Electron runtime by adding a small TypeScript script set under `scripts/issue-bot/`, with focused helpers for Gitee fetching, ranking, local state, approval, and opencode launching. Use user-cache state storage plus `systemd --user` service/timer units, and pass the `~/Desktop/spark-store` + `Erotica`-based worktree requirement into the generated opencode prompt instead of creating worktrees during polling.
**Tech Stack:** Node.js 22 with `--experimental-strip-types`, TypeScript strict mode, built-in `fetch`, Vitest, npm scripts, `systemd --user` units.
---
## File Map
- Create: `scripts/issue-bot/lib/types.ts` — shared strict TypeScript types for normalized issues, ranking results, and persisted state.
- Create: `scripts/issue-bot/lib/state.ts` — state file path resolution, JSON load/save, corruption backup, and default-state initialization.
- Create: `scripts/issue-bot/lib/ranking.ts` — issue filtering, heuristic scoring, and candidate selection.
- Create: `scripts/issue-bot/lib/gitee.ts` — fetch open issues from Gitee API first and normalize the response.
- Create: `scripts/issue-bot/lib/opencode.ts` — build approval prompt and spawn a configured opencode command.
- Create: `scripts/issue-bot/check-issues.ts` — one-shot polling entrypoint that updates `currentCandidate`.
- Create: `scripts/issue-bot/approve-issue.ts` — manual approval entrypoint that launches opencode and marks the approved issue.
- Create: `src/__tests__/unit/issue-bot/state.test.ts` — state initialization, backup, and save/load tests.
- Create: `src/__tests__/unit/issue-bot/ranking.test.ts` — scoring, filtering, and candidate selection tests.
- Create: `src/__tests__/unit/issue-bot/check-issues.test.ts` — polling orchestration tests using mocked fetch/state.
- Create: `src/__tests__/unit/issue-bot/approve-issue.test.ts` — approval and opencode-launch orchestration tests.
- Create: `src/__tests__/unit/issue-bot/packaging.test.ts` — npm script and systemd unit smoke tests.
- Modify: `package.json` — add `issue-bot:check` and `issue-bot:approve` scripts.
- Modify: `tsconfig.node.json` — include `scripts` for type-check coverage in build tooling.
- Create: `extras/systemd/spark-store-issue-bot.service``oneshot` user service for polling.
- Create: `extras/systemd/spark-store-issue-bot.timer` — six-hour persistent timer.
### Task 1: Add Shared Types and Local State Storage
**Files:**
- Create: `scripts/issue-bot/lib/types.ts`
- Create: `scripts/issue-bot/lib/state.ts`
- Test: `src/__tests__/unit/issue-bot/state.test.ts`
- [ ] **Step 1: Write the failing test**
```ts
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
createDefaultIssueBotState,
getIssueBotStatePath,
loadIssueBotState,
saveIssueBotState,
} from "../../../../scripts/issue-bot/lib/state";
describe("issue-bot state", () => {
afterEach(() => {
vi.restoreAllMocks();
delete process.env.XDG_CACHE_HOME;
});
it("uses the XDG cache directory when available", () => {
process.env.XDG_CACHE_HOME = "/tmp/spark-cache";
expect(getIssueBotStatePath()).toBe(
"/tmp/spark-cache/spark-store/issue-bot/state.json",
);
});
it("returns a default state when the file does not exist", () => {
vi.spyOn(fs, "existsSync").mockReturnValue(false);
expect(loadIssueBotState()).toEqual(createDefaultIssueBotState());
});
it("backs up invalid JSON and resets to the default state", () => {
vi.spyOn(fs, "existsSync").mockReturnValue(true);
vi.spyOn(fs, "readFileSync").mockReturnValue("not-json");
const renameSync = vi.spyOn(fs, "renameSync").mockImplementation(() => {});
expect(loadIssueBotState()).toEqual(createDefaultIssueBotState());
expect(renameSync).toHaveBeenCalledWith(
expect.stringContaining("state.json"),
expect.stringContaining("state.json.bak-"),
);
});
it("creates parent directories before saving state", () => {
const mkdirSync = vi
.spyOn(fs, "mkdirSync")
.mockImplementation(() => undefined);
const writeFileSync = vi
.spyOn(fs, "writeFileSync")
.mockImplementation(() => undefined);
saveIssueBotState({
...createDefaultIssueBotState(),
lastRunStatus: "success",
lastRunMessage: "candidate updated",
});
expect(mkdirSync).toHaveBeenCalledWith(
path.dirname(getIssueBotStatePath()),
{ recursive: true },
);
expect(writeFileSync).toHaveBeenCalledWith(
getIssueBotStatePath(),
expect.stringContaining('"lastRunStatus": "success"'),
"utf8",
);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npm run test -- --run src/__tests__/unit/issue-bot/state.test.ts`
Expected: FAIL with `Cannot find module '../../../../scripts/issue-bot/lib/state'`.
- [ ] **Step 3: Write minimal implementation**
```ts
// scripts/issue-bot/lib/types.ts
export interface NormalizedIssue {
id: number;
number: string;
title: string;
url: string;
state: "open" | "closed";
createdAt: string;
updatedAt: string;
labels: string[];
bodyPreview: string;
}
export interface RankedIssue extends NormalizedIssue {
score: number;
rankingReasons: string[];
}
export interface ApprovedIssue {
id: number;
title: string;
url: string;
approvedAt: string;
}
export interface IssueBotState {
currentCandidate: RankedIssue | null;
approvedIssue: ApprovedIssue | null;
seenIssueIds: number[];
lastRunAt: string | null;
lastRunStatus: "idle" | "success" | "network-error" | "parse-error";
lastRunMessage: string | null;
}
```
```ts
// scripts/issue-bot/lib/state.ts
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { IssueBotState } from "./types";
export const createDefaultIssueBotState = (): IssueBotState => ({
currentCandidate: null,
approvedIssue: null,
seenIssueIds: [],
lastRunAt: null,
lastRunStatus: "idle",
lastRunMessage: null,
});
export const getIssueBotStatePath = (): string => {
const cacheRoot =
process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache");
return path.join(cacheRoot, "spark-store", "issue-bot", "state.json");
};
export const loadIssueBotState = (): IssueBotState => {
const filePath = getIssueBotStatePath();
if (!fs.existsSync(filePath)) return createDefaultIssueBotState();
try {
const raw = fs.readFileSync(filePath, "utf8");
return {
...createDefaultIssueBotState(),
...(JSON.parse(raw) as Partial<IssueBotState>),
};
} catch {
const backupPath = `${filePath}.bak-${Date.now()}`;
fs.renameSync(filePath, backupPath);
return createDefaultIssueBotState();
}
};
export const saveIssueBotState = (state: IssueBotState): void => {
const filePath = getIssueBotStatePath();
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
};
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npm run test -- --run src/__tests__/unit/issue-bot/state.test.ts`
Expected: PASS with 4 tests passed.
- [ ] **Step 5: Commit**
```bash
git add scripts/issue-bot/lib/types.ts scripts/issue-bot/lib/state.ts src/__tests__/unit/issue-bot/state.test.ts
git commit -m "feat(issue-bot): add local state storage"
```
### Task 2: Add Ranking Rules and Candidate Selection
**Files:**
- Create: `scripts/issue-bot/lib/ranking.ts`
- Test: `src/__tests__/unit/issue-bot/ranking.test.ts`
- [ ] **Step 1: Write the failing test**
```ts
import { describe, expect, it } from "vitest";
import {
rankIssues,
selectTopIssueCandidate,
} from "../../../../scripts/issue-bot/lib/ranking";
import type { NormalizedIssue } from "../../../../scripts/issue-bot/lib/types";
const makeIssue = (overrides: Partial<NormalizedIssue>): NormalizedIssue => ({
id: 1,
number: "I123",
title: "示例 issue",
url: "https://gitee.com/spark-store-project/spark-store/issues/I123",
state: "open",
createdAt: "2026-04-14T00:00:00.000Z",
updatedAt: "2026-04-14T00:00:00.000Z",
labels: [],
bodyPreview: "用户反馈应用无法安装,并附上了复现步骤和日志。",
...overrides,
});
describe("issue-bot ranking", () => {
it("prioritizes install failures with actionable details", () => {
const ranked = rankIssues([
makeIssue({ id: 1, title: "应用无法安装,附日志" }),
makeIssue({ id: 2, title: "建议增加分类筛选", bodyPreview: "功能建议" }),
]);
expect(ranked[0].id).toBe(1);
expect(ranked[0].score).toBeGreaterThan(ranked[1].score);
expect(ranked[0].rankingReasons).toContain(
"contains high-impact keyword: 无法安装",
);
});
it("filters out closed issues and already-approved issues", () => {
const candidate = selectTopIssueCandidate(
[
makeIssue({ id: 3, state: "closed", title: "已关闭问题" }),
makeIssue({ id: 4, title: "白屏并卡死" }),
],
{ approvedIssueId: 4 },
);
expect(candidate).toBeNull();
});
it("prefers more recently updated issues when scores otherwise match", () => {
const candidate = selectTopIssueCandidate(
[
makeIssue({
id: 5,
title: "启动白屏",
updatedAt: "2026-04-14T08:00:00.000Z",
}),
makeIssue({
id: 6,
title: "启动白屏",
updatedAt: "2026-04-14T09:00:00.000Z",
}),
],
{ approvedIssueId: null },
);
expect(candidate?.id).toBe(6);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npm run test -- --run src/__tests__/unit/issue-bot/ranking.test.ts`
Expected: FAIL with `Cannot find module '../../../../scripts/issue-bot/lib/ranking'`.
- [ ] **Step 3: Write minimal implementation**
```ts
// scripts/issue-bot/lib/ranking.ts
import type { NormalizedIssue, RankedIssue } from "./types";
const HIGH_IMPACT_KEYWORDS = [
"崩溃",
"打不开",
"无法安装",
"升级失败",
"卡死",
"白屏",
"闪退",
];
const CORE_FLOW_KEYWORDS = ["安装", "卸载", "更新", "启动", "搜索", "加载"];
const hasActionableDetail = (issue: NormalizedIssue): boolean =>
/复现|日志|截图|error|错误/i.test(issue.bodyPreview);
const scoreIssue = (issue: NormalizedIssue): RankedIssue => {
const reasons: string[] = [];
let score = 0;
const haystack = `${issue.title}\n${issue.bodyPreview}`;
for (const keyword of HIGH_IMPACT_KEYWORDS) {
if (haystack.includes(keyword)) {
score += 10;
reasons.push(`contains high-impact keyword: ${keyword}`);
}
}
for (const keyword of CORE_FLOW_KEYWORDS) {
if (haystack.includes(keyword)) {
score += 4;
reasons.push(`touches core flow: ${keyword}`);
break;
}
}
if (hasActionableDetail(issue)) {
score += 6;
reasons.push("includes actionable detail");
}
if (/建议|需求|希望/.test(haystack)) {
score -= 4;
reasons.push("looks like feature discussion");
}
return {
...issue,
score,
rankingReasons: reasons,
};
};
export const rankIssues = (issues: NormalizedIssue[]): RankedIssue[] =>
[...issues]
.filter((issue) => issue.state === "open")
.map(scoreIssue)
.sort((left, right) => {
if (right.score !== left.score) return right.score - left.score;
return Date.parse(right.updatedAt) - Date.parse(left.updatedAt);
});
export const selectTopIssueCandidate = (
issues: NormalizedIssue[],
options: { approvedIssueId: number | null },
): RankedIssue | null => {
const ranked = rankIssues(issues).filter(
(issue) => issue.id !== options.approvedIssueId,
);
return ranked[0] ?? null;
};
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npm run test -- --run src/__tests__/unit/issue-bot/ranking.test.ts`
Expected: PASS with 3 tests passed.
- [ ] **Step 5: Commit**
```bash
git add scripts/issue-bot/lib/ranking.ts src/__tests__/unit/issue-bot/ranking.test.ts
git commit -m "feat(issue-bot): rank candidate issues"
```
### Task 3: Add Gitee Fetching and Polling Entrypoint
**Files:**
- Create: `scripts/issue-bot/lib/gitee.ts`
- Create: `scripts/issue-bot/check-issues.ts`
- Test: `src/__tests__/unit/issue-bot/check-issues.test.ts`
- [ ] **Step 1: Write the failing test**
```ts
import { beforeEach, describe, expect, it, vi } from "vitest";
import type {
IssueBotState,
NormalizedIssue,
} from "../../../../scripts/issue-bot/lib/types";
const loadState = vi.fn();
const saveState = vi.fn();
const listOpenIssues = vi.fn();
vi.mock("../../../../scripts/issue-bot/lib/state", () => ({
createDefaultIssueBotState: () => ({
currentCandidate: null,
approvedIssue: null,
seenIssueIds: [],
lastRunAt: null,
lastRunStatus: "idle",
lastRunMessage: null,
}),
loadIssueBotState: loadState,
saveIssueBotState: saveState,
}));
vi.mock("../../../../scripts/issue-bot/lib/gitee", () => ({
listOpenIssues,
}));
describe("check-issues", () => {
beforeEach(() => {
vi.resetModules();
loadState.mockReset();
saveState.mockReset();
listOpenIssues.mockReset();
});
it("stores the top-ranked issue candidate", async () => {
const baseState: IssueBotState = {
currentCandidate: null,
approvedIssue: null,
seenIssueIds: [],
lastRunAt: null,
lastRunStatus: "idle",
lastRunMessage: null,
};
loadState.mockReturnValue(baseState);
listOpenIssues.mockResolvedValue([
{
id: 10,
number: "I10",
title: "应用无法安装并白屏",
url: "https://gitee.com/spark-store-project/spark-store/issues/I10",
state: "open",
createdAt: "2026-04-14T00:00:00.000Z",
updatedAt: "2026-04-14T09:00:00.000Z",
labels: ["bug"],
bodyPreview: "复现步骤:1. 打开商店 2. 点击安装。附日志。",
},
] satisfies NormalizedIssue[]);
const { runIssueBotCheck } =
await import("../../../../scripts/issue-bot/check-issues");
await runIssueBotCheck();
expect(saveState).toHaveBeenCalledWith(
expect.objectContaining({
currentCandidate: expect.objectContaining({
id: 10,
title: "应用无法安装并白屏",
}),
lastRunStatus: "success",
}),
);
});
it("keeps the previous candidate when fetching issues fails", async () => {
loadState.mockReturnValue({
currentCandidate: {
id: 99,
number: "I99",
title: "旧候选",
url: "https://gitee.com/spark-store-project/spark-store/issues/I99",
state: "open",
createdAt: "2026-04-14T00:00:00.000Z",
updatedAt: "2026-04-14T00:00:00.000Z",
labels: [],
bodyPreview: "旧摘要",
score: 12,
rankingReasons: ["legacy candidate"],
},
approvedIssue: null,
seenIssueIds: [],
lastRunAt: null,
lastRunStatus: "idle",
lastRunMessage: null,
});
listOpenIssues.mockRejectedValue(new Error("network down"));
const { runIssueBotCheck } =
await import("../../../../scripts/issue-bot/check-issues");
await runIssueBotCheck();
expect(saveState).toHaveBeenCalledWith(
expect.objectContaining({
currentCandidate: expect.objectContaining({ id: 99 }),
lastRunStatus: "network-error",
lastRunMessage: "network down",
}),
);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npm run test -- --run src/__tests__/unit/issue-bot/check-issues.test.ts`
Expected: FAIL with `Cannot find module '../../../../scripts/issue-bot/check-issues'`.
- [ ] **Step 3: Write minimal implementation**
```ts
// scripts/issue-bot/lib/gitee.ts
import type { NormalizedIssue } from "./types";
interface GiteeIssueApiResponse {
id: number;
number: string;
title: string;
state: "open" | "closed";
created_at: string;
updated_at: string;
body?: string;
html_url: string;
labels?: Array<{ name?: string }>;
}
const GITEE_ISSUES_API_URL =
"https://gitee.com/api/v5/repos/spark-store-project/spark-store/issues?state=open&sort=updated&direction=desc&page=1&per_page=50";
export const listOpenIssues = async (): Promise<NormalizedIssue[]> => {
const response = await fetch(GITEE_ISSUES_API_URL);
if (!response.ok) {
throw new Error(`Gitee request failed: ${response.status}`);
}
const payload = (await response.json()) as GiteeIssueApiResponse[];
return payload.map((issue) => ({
id: issue.id,
number: issue.number,
title: issue.title,
url: issue.html_url,
state: issue.state,
createdAt: issue.created_at,
updatedAt: issue.updated_at,
labels: (issue.labels || [])
.map((label) => label.name?.trim() || "")
.filter((label) => label.length > 0),
bodyPreview: (issue.body || "").slice(0, 500),
}));
};
```
```ts
// scripts/issue-bot/check-issues.ts
import { listOpenIssues } from "./lib/gitee";
import { selectTopIssueCandidate } from "./lib/ranking";
import { loadIssueBotState, saveIssueBotState } from "./lib/state";
export const runIssueBotCheck = async (): Promise<void> => {
const state = loadIssueBotState();
const now = new Date().toISOString();
try {
const issues = await listOpenIssues();
const candidate = selectTopIssueCandidate(issues, {
approvedIssueId: state.approvedIssue?.id ?? null,
});
saveIssueBotState({
...state,
currentCandidate: candidate,
seenIssueIds: candidate
? Array.from(new Set([...state.seenIssueIds, candidate.id]))
: state.seenIssueIds,
lastRunAt: now,
lastRunStatus: "success",
lastRunMessage: candidate
? `candidate updated: ${candidate.title}`
: "no candidate issues found",
});
} catch (error) {
saveIssueBotState({
...state,
lastRunAt: now,
lastRunStatus: "network-error",
lastRunMessage: error instanceof Error ? error.message : String(error),
});
}
};
if (import.meta.url === `file://${process.argv[1]}`) {
runIssueBotCheck().catch((error) => {
console.error(error);
process.exitCode = 1;
});
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npm run test -- --run src/__tests__/unit/issue-bot/check-issues.test.ts`
Expected: PASS with 2 tests passed.
- [ ] **Step 5: Commit**
```bash
git add scripts/issue-bot/lib/gitee.ts scripts/issue-bot/check-issues.ts src/__tests__/unit/issue-bot/check-issues.test.ts
git commit -m "feat(issue-bot): poll gitee issues"
```
### Task 4: Add Opencode Prompt Generation and Manual Approval
**Files:**
- Create: `scripts/issue-bot/lib/opencode.ts`
- Create: `scripts/issue-bot/approve-issue.ts`
- Test: `src/__tests__/unit/issue-bot/approve-issue.test.ts`
- [ ] **Step 1: Write the failing test**
```ts
import { beforeEach, describe, expect, it, vi } from "vitest";
const loadState = vi.fn();
const saveState = vi.fn();
const launchOpencodeForIssue = vi.fn();
vi.mock("../../../../scripts/issue-bot/lib/state", () => ({
loadIssueBotState: loadState,
saveIssueBotState: saveState,
}));
vi.mock("../../../../scripts/issue-bot/lib/opencode", () => ({
launchOpencodeForIssue,
}));
describe("approve-issue", () => {
beforeEach(() => {
vi.resetModules();
loadState.mockReset();
saveState.mockReset();
launchOpencodeForIssue.mockReset();
});
it("marks the current candidate as approved and launches opencode", async () => {
loadState.mockReturnValue({
currentCandidate: {
id: 42,
number: "I42",
title: "应用升级失败并白屏",
url: "https://gitee.com/spark-store-project/spark-store/issues/I42",
state: "open",
createdAt: "2026-04-14T00:00:00.000Z",
updatedAt: "2026-04-14T00:00:00.000Z",
labels: ["bug"],
bodyPreview: "更新后白屏,附日志。",
score: 20,
rankingReasons: ["contains high-impact keyword: 升级失败"],
},
approvedIssue: null,
seenIssueIds: [42],
lastRunAt: "2026-04-14T09:00:00.000Z",
lastRunStatus: "success",
lastRunMessage: "candidate updated",
});
const { runIssueBotApproval } =
await import("../../../../scripts/issue-bot/approve-issue");
await runIssueBotApproval();
expect(launchOpencodeForIssue).toHaveBeenCalledWith(
expect.objectContaining({ id: 42, title: "应用升级失败并白屏" }),
);
expect(saveState).toHaveBeenCalledWith(
expect.objectContaining({
currentCandidate: null,
approvedIssue: expect.objectContaining({ id: 42 }),
}),
);
});
it("throws when there is no candidate to approve", async () => {
loadState.mockReturnValue({
currentCandidate: null,
approvedIssue: null,
seenIssueIds: [],
lastRunAt: null,
lastRunStatus: "idle",
lastRunMessage: null,
});
const { runIssueBotApproval } =
await import("../../../../scripts/issue-bot/approve-issue");
await expect(runIssueBotApproval()).rejects.toThrow(
"No current issue candidate to approve.",
);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npm run test -- --run src/__tests__/unit/issue-bot/approve-issue.test.ts`
Expected: FAIL with `Cannot find module '../../../../scripts/issue-bot/approve-issue'`.
- [ ] **Step 3: Write minimal implementation**
```ts
// scripts/issue-bot/lib/opencode.ts
import { spawn } from "node:child_process";
import type { RankedIssue } from "./types";
export const buildOpencodePrompt = (
issue: RankedIssue,
): string => `请处理以下 Spark Store issue
标题:${issue.title}
链接:${issue.url}
摘要:${issue.bodyPreview}
优先级原因:${issue.rankingReasons.join("")}
要求:先分析根因,再开始修复。默认基仓库必须使用 ~/Desktop/spark-store。
如果开始修改代码,必须先使用 git worktree,从 Erotica 分支开出新的工作分支,并在该 worktree 中实施改动,不要直接在主工作区修改。`;
export const launchOpencodeForIssue = async (
issue: RankedIssue,
): Promise<void> => {
const configuredCommand = process.env.SPARK_STORE_OPENCODE_CMD || "opencode";
const child = spawn(configuredCommand, [buildOpencodePrompt(issue)], {
detached: true,
stdio: "ignore",
shell: true,
});
child.unref();
};
```
```ts
// scripts/issue-bot/approve-issue.ts
import { launchOpencodeForIssue } from "./lib/opencode";
import { loadIssueBotState, saveIssueBotState } from "./lib/state";
export const runIssueBotApproval = async (): Promise<void> => {
const state = loadIssueBotState();
const candidate = state.currentCandidate;
if (!candidate) {
throw new Error("No current issue candidate to approve.");
}
await launchOpencodeForIssue(candidate);
saveIssueBotState({
...state,
currentCandidate: null,
approvedIssue: {
id: candidate.id,
title: candidate.title,
url: candidate.url,
approvedAt: new Date().toISOString(),
},
});
};
if (import.meta.url === `file://${process.argv[1]}`) {
runIssueBotApproval().catch((error) => {
console.error(error);
process.exitCode = 1;
});
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npm run test -- --run src/__tests__/unit/issue-bot/approve-issue.test.ts`
Expected: PASS with 2 tests passed.
- [ ] **Step 5: Commit**
```bash
git add scripts/issue-bot/lib/opencode.ts scripts/issue-bot/approve-issue.ts src/__tests__/unit/issue-bot/approve-issue.test.ts
git commit -m "feat(issue-bot): approve candidates and launch opencode"
```
### Task 5: Wire npm Scripts and systemd Units
**Files:**
- Modify: `package.json`
- Modify: `tsconfig.node.json`
- Create: `extras/systemd/spark-store-issue-bot.service`
- Create: `extras/systemd/spark-store-issue-bot.timer`
- Create: `src/__tests__/unit/issue-bot/packaging.test.ts`
- [ ] **Step 1: Write the failing test**
```ts
import { describe, expect, it } from "vitest";
import pkg from "../../../../package.json";
import serviceUnit from "../../../../extras/systemd/spark-store-issue-bot.service?raw";
import timerUnit from "../../../../extras/systemd/spark-store-issue-bot.timer?raw";
describe("issue-bot packaging", () => {
it("adds npm scripts for polling and approval", () => {
expect(pkg.scripts["issue-bot:check"]).toBe(
"node --experimental-strip-types scripts/issue-bot/check-issues.ts",
);
expect(pkg.scripts["issue-bot:approve"]).toBe(
"node --experimental-strip-types scripts/issue-bot/approve-issue.ts",
);
});
it("installs a six-hour persistent user timer", () => {
expect(serviceUnit).toContain("Type=oneshot");
expect(serviceUnit).toContain(
"ExecStart=/usr/bin/env npm run issue-bot:check",
);
expect(timerUnit).toContain("OnUnitActiveSec=6h");
expect(timerUnit).toContain("Persistent=true");
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npm run test -- --run src/__tests__/unit/issue-bot/packaging.test.ts`
Expected: FAIL with `Failed to resolve import '../../../../extras/systemd/spark-store-issue-bot.service?raw'` and missing package scripts.
- [ ] **Step 3: Write minimal implementation**
```json
// package.json
{
"scripts": {
"issue-bot:check": "node --experimental-strip-types scripts/issue-bot/check-issues.ts",
"issue-bot:approve": "node --experimental-strip-types scripts/issue-bot/approve-issue.ts"
}
}
```
```json
// tsconfig.node.json
{
"include": ["vite.config.ts", "package.json", "electron", "scripts"]
}
```
```ini
; extras/systemd/spark-store-issue-bot.service
[Unit]
Description=Spark Store issue bot poller
[Service]
Type=oneshot
WorkingDirectory=%h/Desktop/spark-store
ExecStart=/usr/bin/env npm run issue-bot:check
```
```ini
; extras/systemd/spark-store-issue-bot.timer
[Unit]
Description=Run Spark Store issue bot every 6 hours
[Timer]
OnBootSec=15m
OnUnitActiveSec=6h
Persistent=true
Unit=spark-store-issue-bot.service
[Install]
WantedBy=timers.target
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npm run test -- --run src/__tests__/unit/issue-bot/packaging.test.ts`
Expected: PASS with 2 tests passed.
- [ ] **Step 5: Commit**
```bash
git add package.json tsconfig.node.json extras/systemd/spark-store-issue-bot.service extras/systemd/spark-store-issue-bot.timer src/__tests__/unit/issue-bot/packaging.test.ts
git commit -m "chore(issue-bot): wire scripts and timer units"
```
### Task 6: Run End-to-End Verification
**Files:**
- Modify: `scripts/issue-bot/check-issues.ts`
- Modify: `scripts/issue-bot/approve-issue.ts`
- Modify: `scripts/issue-bot/lib/gitee.ts`
- Modify: `scripts/issue-bot/lib/opencode.ts`
- Modify: `scripts/issue-bot/lib/ranking.ts`
- Modify: `scripts/issue-bot/lib/state.ts`
- Modify: `package.json`
- Modify: `tsconfig.node.json`
- Create: `extras/systemd/spark-store-issue-bot.service`
- Create: `extras/systemd/spark-store-issue-bot.timer`
- Test: `src/__tests__/unit/issue-bot/state.test.ts`
- Test: `src/__tests__/unit/issue-bot/ranking.test.ts`
- Test: `src/__tests__/unit/issue-bot/check-issues.test.ts`
- Test: `src/__tests__/unit/issue-bot/approve-issue.test.ts`
- Test: `src/__tests__/unit/issue-bot/packaging.test.ts`
- [ ] **Step 1: Run focused issue-bot tests**
Run: `npm run test -- --run src/__tests__/unit/issue-bot/state.test.ts src/__tests__/unit/issue-bot/ranking.test.ts src/__tests__/unit/issue-bot/check-issues.test.ts src/__tests__/unit/issue-bot/approve-issue.test.ts src/__tests__/unit/issue-bot/packaging.test.ts`
Expected: PASS with all issue-bot tests green.
- [ ] **Step 2: Run lint**
Run: `npm run lint`
Expected: PASS with no ESLint errors in `scripts/issue-bot`, `src/__tests__/unit/issue-bot`, and touched config files.
- [ ] **Step 3: Run build verification**
Run: `npm run build:vite`
Expected: PASS with Electron/Vite bundles generated and no TypeScript errors after adding `scripts` to `tsconfig.node.json`.
- [ ] **Step 4: Manually verify CLI entrypoints**
Run: `npm run issue-bot:check`
Expected: `~/.cache/spark-store/issue-bot/state.json` exists and contains either a populated `currentCandidate` or a `lastRunMessage` of `no candidate issues found`.
Run: `SPARK_STORE_OPENCODE_CMD='printf' npm run issue-bot:approve`
Expected: command exits successfully and prints the generated prompt containing both `~/Desktop/spark-store` and `Erotica`.
- [ ] **Step 5: Manually verify systemd units**
Run: `systemctl --user start spark-store-issue-bot.service`
Expected: service runs once without unit-file syntax errors.
Run: `systemctl --user enable --now spark-store-issue-bot.timer`
Expected: timer is enabled, active, and reports the next run roughly 6 hours later.
- [ ] **Step 6: Commit**
```bash
git add scripts/issue-bot package.json tsconfig.node.json extras/systemd/spark-store-issue-bot.service extras/systemd/spark-store-issue-bot.timer src/__tests__/unit/issue-bot
git commit -m "feat(issue-bot): add automated issue polling workflow"
```
## Self-Review
### Spec coverage
- `systemd --user` timer requirement: covered by Task 5 and Task 6.
- One-candidate ranking with explainable reasons: covered by Task 2 and Task 3.
- Manual approval before opencode launch: covered by Task 4.
- Local cache-backed state with failure retention: covered by Task 1 and Task 3.
- `~/Desktop/spark-store` + `Erotica` worktree rule in the launch prompt: covered by Task 4 and manual verification in Task 6.
### Placeholder scan
- No `TBD`, `TODO`, or “implement later” placeholders remain.
- All code-changing steps include concrete code blocks.
- All verification steps include exact commands and expected outcomes.
### Type consistency
- `NormalizedIssue`, `RankedIssue`, `ApprovedIssue`, and `IssueBotState` are defined in Task 1 and reused consistently in Tasks 2-4.
- `runIssueBotCheck`, `runIssueBotApproval`, and `launchOpencodeForIssue` names stay unchanged across tests and implementation steps.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,152 @@
# Installed Apps Modal Actions Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Restore launch and detail entry points from the installed-apps modal by wiring explicit `打开` and `查看详情` actions back to the existing parent handlers.
**Architecture:** Keep the fix local to the installed-apps modal and `App.vue`. Add two emitted events from `InstalledAppsModal.vue`, conditionally render the detail action when the app has usable store metadata, and connect those events to the existing `openDownloadedApp()` and `openDetail()` logic in the parent.
**Tech Stack:** Vue 3, TypeScript, Vitest, Testing Library Vue
---
## File Structure
- Modify: `src/components/InstalledAppsModal.vue`
Responsibility: render open/detail actions for installed app rows and emit events upward.
- Modify: `src/App.vue`
Responsibility: wire modal events to existing launch/detail handlers.
- Modify: `src/__tests__/unit/InstalledAppsModal.test.ts`
Responsibility: prove action buttons render and emit correctly.
### Task 1: Add Failing Modal Tests
**Files:**
- Modify: `src/__tests__/unit/InstalledAppsModal.test.ts`
- [ ] **Step 1: Write failing tests for open/detail actions**
```ts
it("renders open and detail actions for a store-backed installed app", async () => {
// render with one installed app whose category is not unknown
// expect 打开 and 查看详情 buttons to exist
});
it("emits open-app when clicking 打开", async () => {
// click open button
// expect emitted()['open-app']
});
it("emits open-detail when clicking 查看详情", async () => {
// click detail button
// expect emitted()['open-detail']
});
it("hides 查看详情 for unknown-category apps", () => {
// render app with category unknown
// expect no 查看详情 button
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npx vitest run src/__tests__/unit/InstalledAppsModal.test.ts`
Expected: FAIL because the modal does not yet render or emit the new actions
### Task 2: Implement Modal Actions
**Files:**
- Modify: `src/components/InstalledAppsModal.vue`
- Modify: `src/App.vue`
- [ ] **Step 1: Add minimal modal action rendering and emits**
```ts
defineEmits<{
(e: "close"): void;
(e: "refresh"): void;
(e: "uninstall", app: App): void;
(e: "switch-origin", origin: "apm" | "spark"): void;
(e: "open-app", app: App): void;
(e: "open-detail", app: App): void;
}>();
```
- [ ] **Step 2: Add a simple detail-eligibility helper**
```ts
const canOpenDetail = (app: App) => {
return (
app.category !== "unknown" ||
Boolean(app.more) ||
Boolean(app.website) ||
Boolean(app.author) ||
(app.img_urls?.length ?? 0) > 0
);
};
```
- [ ] **Step 3: Add 打开 / 查看详情 buttons to each row**
```vue
<button type="button" @click="$emit('open-app', app)">打开</button>
<button
v-if="canOpenDetail(app)"
type="button"
@click="$emit('open-detail', app)"
>
查看详情
</button>
```
- [ ] **Step 4: Wire parent events to existing handlers**
```vue
<InstalledAppsModal
...
@open-app="openDownloadedApp($event.pkgname, $event.origin)"
@open-detail="openDetail"
/>
```
- [ ] **Step 5: Run test to verify it passes**
Run: `npx vitest run src/__tests__/unit/InstalledAppsModal.test.ts`
Expected: PASS
### Task 3: Verification And Commit
**Files:**
- Modify: `src/components/InstalledAppsModal.vue`
- Modify: `src/App.vue`
- Modify: `src/__tests__/unit/InstalledAppsModal.test.ts`
- [ ] **Step 1: Run focused modal test**
Run: `npx vitest run src/__tests__/unit/InstalledAppsModal.test.ts`
Expected: PASS
- [ ] **Step 2: Run repository lint**
Run: `npm run lint`
Expected: exit 0
- [ ] **Step 3: Run repository build**
Run: `npm run build:vite`
Expected: exit 0
- [ ] **Step 4: Review final diff**
Run: `git diff -- src/components/InstalledAppsModal.vue src/App.vue src/__tests__/unit/InstalledAppsModal.test.ts docs/superpowers/specs/2026-04-15-installed-apps-modal-actions-design.md docs/superpowers/plans/2026-04-15-installed-apps-modal-actions.md`
Expected: only installed-app actions and docs changes appear
- [ ] **Step 5: Commit**
```bash
git add src/components/InstalledAppsModal.vue src/App.vue src/__tests__/unit/InstalledAppsModal.test.ts docs/superpowers/specs/2026-04-15-installed-apps-modal-actions-design.md docs/superpowers/plans/2026-04-15-installed-apps-modal-actions.md
git commit -m "fix(installed-apps): restore open and detail actions"
```
@@ -0,0 +1,105 @@
# Update Ignore Configuration Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Move update-ignore persistence to user config, add ignore and unignore controls to the Electron update center, and make the legacy Qt updater plus root notifier honor the same `pkg|newVersion` rules.
**Architecture:** Keep the existing text config format and IPC channels. Change the default config path in the Electron backend, expose ignore actions in the renderer store and item component, align the Qt updater with the same new-version key semantics, and teach the notifier to discover user config files without trusting root `HOME`.
**Tech Stack:** TypeScript, Vue 3, Electron IPC, Vitest, Qt/C++, POSIX shell
---
## File Structure
- Modify: `electron/main/backend/update-center/ignore-config.ts`
Responsibility: switch the default ignore config path to the user config directory and keep exact `pkg|version` matching.
- Modify: `electron/main/backend/update-center/service.ts`
Responsibility: apply ignored sorting after refresh.
- Modify: `src/modules/updateCenter.ts`
Responsibility: expose ignore and unignore actions to the renderer.
- Modify: `src/components/update-center/UpdateCenterItem.vue`
Responsibility: render ignore and unignore controls for each item.
- Modify: `src/components/update-center/UpdateCenterList.vue`
Responsibility: bubble ignore and unignore item events upward.
- Modify: `src/components/UpdateCenterModal.vue`
Responsibility: wire ignore and unignore item events to the store.
- Modify: `src/__tests__/unit/update-center/ignore-config.test.ts`
Responsibility: prove the new default path resolves to the user config directory.
- Modify: `src/__tests__/unit/update-center/store.test.ts`
Responsibility: prove ignore and unignore call the preload bridge and refresh state.
- Modify: `src/__tests__/unit/update-center/UpdateCenterModal.test.ts`
Responsibility: prove ignore-state actions render correctly.
- Modify: `spark-update-tool/src/ignoreconfig.cpp`
Responsibility: move the Qt config path to the user config directory.
- Modify: `spark-update-tool/src/ignoreconfig.h`
Responsibility: support exact unignore by package plus version.
- Modify: `spark-update-tool/src/appdelegate.cpp`
Responsibility: emit the target new version when ignoring or unignoring.
- Modify: `spark-update-tool/src/appdelegate.h`
Responsibility: update the unignore signal signature.
- Modify: `spark-update-tool/src/mainwindow.cpp`
Responsibility: match ignored state against new versions and remove exact entries.
- Modify: `spark-update-tool/src/mainwindow.h`
Responsibility: update slot signatures.
- Modify: `tool/update-upgrade/ss-update-notifier.sh`
Responsibility: locate user config files from a root service context and filter exact `pkg|newVersion` matches.
## Task 1: Electron Ignore Path And Renderer Actions
**Files:**
- Modify: `electron/main/backend/update-center/ignore-config.ts`
- Modify: `electron/main/backend/update-center/service.ts`
- Modify: `src/modules/updateCenter.ts`
- Modify: `src/components/update-center/UpdateCenterItem.vue`
- Modify: `src/components/update-center/UpdateCenterList.vue`
- Modify: `src/components/UpdateCenterModal.vue`
- Modify: `src/__tests__/unit/update-center/ignore-config.test.ts`
- Modify: `src/__tests__/unit/update-center/store.test.ts`
- Modify: `src/__tests__/unit/update-center/UpdateCenterModal.test.ts`
- [ ] Write failing tests for the new user config path, ignore/unignore store methods, and item actions.
- [ ] Run `npx vitest run src/__tests__/unit/update-center/ignore-config.test.ts src/__tests__/unit/update-center/store.test.ts src/__tests__/unit/update-center/UpdateCenterModal.test.ts` and confirm they fail for the expected reasons.
- [ ] Implement the minimal backend path change, item sorting, renderer store methods, and modal wiring.
- [ ] Re-run the same Vitest command and confirm it passes.
## Task 2: Legacy Qt Updater Alignment
**Files:**
- Modify: `spark-update-tool/src/ignoreconfig.cpp`
- Modify: `spark-update-tool/src/ignoreconfig.h`
- Modify: `spark-update-tool/src/appdelegate.cpp`
- Modify: `spark-update-tool/src/appdelegate.h`
- Modify: `spark-update-tool/src/mainwindow.cpp`
- Modify: `spark-update-tool/src/mainwindow.h`
- [ ] Change Qt config path resolution to `QStandardPaths::ConfigLocation/spark-store/ignored_apps.conf`.
- [ ] Switch ignore and unignore to use `packageName + newVersion` exact entries.
- [ ] Build-check the Qt target if a local build command is available.
## Task 3: Root Notifier User Config Discovery
**Files:**
- Modify: `tool/update-upgrade/ss-update-notifier.sh`
- [ ] Add shell helpers to detect a desktop user home when possible.
- [ ] Add fallback scanning across `/home/*/.config/spark-store/ignored_apps.conf`.
- [ ] Merge all discovered config files into one ignore set.
- [ ] Filter updates by exact `pkg|newVersion` instead of package-only.
- [ ] Run `bash -n tool/update-upgrade/ss-update-notifier.sh` and confirm syntax is valid.
## Task 4: Verification And Commit
**Files:**
- Modify: tracked files from Tasks 1-3
- [ ] Run `npx vitest run src/__tests__/unit/update-center/ignore-config.test.ts src/__tests__/unit/update-center/store.test.ts src/__tests__/unit/update-center/UpdateCenterModal.test.ts`.
- [ ] Run `npm run lint`.
- [ ] Run `npm run build`.
- [ ] Run `bash -n tool/update-upgrade/ss-update-notifier.sh`.
- [ ] Review the final diff.
- [ ] Create a commit with a message in repository style.
@@ -0,0 +1,157 @@
# Update Notifier APM Aggregation Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Extend `tool/update-upgrade/ss-update-notifier.sh` so one notifier aggregates effective Spark and APM updates, honoring `hold` status and shared ignored entries while skipping the Spark branch when `aptss` is unavailable.
**Architecture:** Keep the current notifier script as the single entrypoint and add a second APM counting branch beside the existing Spark branch. Reuse the existing ignored-entry loading logic, count Spark and APM updates independently after source-specific filtering, then combine the remaining counts into one notification.
**Tech Stack:** Bash, aptss, apm, amber-pm-debug, dpkg-query
---
## File Structure
- Modify: `tool/update-upgrade/ss-update-notifier.sh`
Responsibility: add APM update parsing and counting, guard Spark execution behind `aptss` availability, reuse ignored-entry filtering for both branches, and keep one aggregated notification path.
### Task 1: Add Source-Specific Counting Helpers
**Files:**
- Modify: `tool/update-upgrade/ss-update-notifier.sh`
- [ ] **Step 1: Write the failing shell behavior expectation as comments in the plan**
```bash
# Expected behavior after implementation:
# 1. If aptss is missing, the script does not call aptss update/ssupdate.
# 2. If apm reports upgradable apps, ignored pkg|newVersion entries suppress them.
# 3. Spark and APM effective counts are added into one final count.
```
- [ ] **Step 2: Run syntax check before changes**
Run: `bash -n tool/update-upgrade/ss-update-notifier.sh`
Expected: exit 0
- [ ] **Step 3: Add minimal helper functions for APM parsing and per-source counting**
```bash
function has-command() {
command -v "$1" >/dev/null 2>&1
}
function get_apm_upgradable_list() {
local output
output=$(env LANGUAGE=en_US apm list --upgradable 2>/dev/null | awk 'NR>1')
local ifs_old="$IFS"
IFS=$'\n'
for line in $output; do
local pkg_name
local pkg_new_ver
local pkg_cur_ver
pkg_name=$(echo "$line" | awk -F '/' '{print $1}')
pkg_new_ver=$(echo "$line" | awk '{print $2}')
pkg_cur_ver=$(printf '%s\n' "$line" | sed -n 's/.*\[\(upgradable from\|from\):[[:space:]]*\([^]]*\)\].*/\2/p')
if [ -n "$pkg_name" ] && [ -n "$pkg_new_ver" ] && [ -n "$pkg_cur_ver" ]; then
echo "$pkg_name $pkg_new_ver $pkg_cur_ver"
fi
done
IFS="$ifs_old"
}
```
- [ ] **Step 4: Re-run syntax check after helper changes**
Run: `bash -n tool/update-upgrade/ss-update-notifier.sh`
Expected: exit 0
### Task 2: Aggregate Spark And APM Effective Counts
**Files:**
- Modify: `tool/update-upgrade/ss-update-notifier.sh`
- [ ] **Step 1: Guard Spark refresh and counting behind aptss availability**
```bash
spark_update_count=0
if has-command aptss; then
# existing aptss update / aptss ssupdate logic
# existing spark upgradable counting logic
fi
```
- [ ] **Step 2: Add APM refresh and counting branch with hold + ignored filtering**
```bash
apm_update_count=0
if has-command apm; then
updatetext=$(LANGUAGE=en_US apm update 2>&1)
# retry loop matching current script style
apm clean
PKG_LIST="$(get_apm_upgradable_list)"
apm_update_count=$(printf '%s\n' "$PKG_LIST" | awk 'NF { count++ } END { print count + 0 }')
# for each package:
# - skip if new <= current
# - skip if amber-pm-debug dpkg-query says hold
# - skip if ignored_apps["$PKG_NAME|$PKG_NEW_VER"] exists
# - otherwise increment apm_update_count
fi
```
- [ ] **Step 3: Replace single-source final count with aggregated count**
```bash
update_app_number=$((spark_update_count + apm_update_count))
if [ "$update_app_number" -le 0 ]; then
exit 0
fi
```
- [ ] **Step 4: Keep one final notification path**
```bash
notify-send -a spark-store \
"${TRANSHELL_CONTENT_SPARK_STORE_UPGRADE_NOTIFY}" \
"${TRANSHELL_CONTENT_THERE_ARE_APPS_TO_UPGRADE}" || true
```
- [ ] **Step 5: Re-run syntax check after aggregation changes**
Run: `bash -n tool/update-upgrade/ss-update-notifier.sh`
Expected: exit 0
### Task 3: Verification And Commit
**Files:**
- Modify: `tool/update-upgrade/ss-update-notifier.sh`
- [ ] **Step 1: Run notifier syntax verification**
Run: `bash -n tool/update-upgrade/ss-update-notifier.sh`
Expected: exit 0
- [ ] **Step 2: Run repository lint**
Run: `npm run lint`
Expected: exit 0
- [ ] **Step 3: Run repository build**
Run: `npm run build:vite`
Expected: exit 0
- [ ] **Step 4: Review final diff**
Run: `git diff -- tool/update-upgrade/ss-update-notifier.sh docs/superpowers/specs/2026-04-15-update-notifier-apm-aggregation-design.md docs/superpowers/plans/2026-04-15-update-notifier-apm-aggregation.md`
Expected: only notifier aggregation and spec/plan changes appear
- [ ] **Step 5: Commit**
```bash
git add tool/update-upgrade/ss-update-notifier.sh docs/superpowers/specs/2026-04-15-update-notifier-apm-aggregation-design.md docs/superpowers/plans/2026-04-15-update-notifier-apm-aggregation.md
git commit -m "fix(update): 聚合 Spark 和 APM 升级通知"
```
@@ -0,0 +1,365 @@
# Gitee Issue 巡检与 Opencode 启动设计
## 背景
当前仓库没有一个稳定的自动化流程,能够按固定周期检查 `https://gitee.com/spark-store-project/spark-store/issues`,筛出当前“最新且最重要”的 issue,并在人工确认后自动拉起新的 opencode 进程开始分析与修复。
你的目标不是让机器人直接静默修复,而是建立一个半自动流程:
1. 每 6 小时自动检查一次 Gitee issues。
2. 自动筛出 1 个当前最值得处理的候选 issue。
3. 默认只汇报,不自动开始修改。
4. 你确认后,自动打开新的 opencode 窗口开始处理。
5. 后续实际开始修改代码时,仍然以 `~/Desktop/spark-store` 作为基仓库,但必须通过 git worktree 从 `Erotica` 分支开出新分支,在隔离工作区中执行修改。
## 目标
1. 使用 `systemd --user` 定时器实现每 6 小时自动巡检。
2. 每轮最多选择 1 个 issue 作为候选项。
3. 候选项必须有可解释的评分结果,便于人工确认。
4. 默认不自动修复,只记录候选状态并等待批准。
5. 批准后自动启动新的 opencode 窗口,并把 issue 上下文传入。
6. 为后续修复流程固定 worktree 约束:从 `Erotica` 分支开新分支,并保持 `~/Desktop/spark-store` 作为主仓库入口。
7. 整个方案尽量独立于 Electron 主进程现有运行逻辑,避免把定时调度耦合进应用本体。
## 非目标
1. 不在本次实现中加入“自动修复后自动提交 PR”之类更长的链路。
2. 不在本次实现中加入应用内 GUI 审批界面。
3. 不在本次实现中实现复杂的 AI 优先级判断;优先使用透明、可维护的规则评分。
4. 不在本次实现中把 issue 处理结果自动回写到 Gitee。
5. 不在本次实现中实际创建 worktree 并改代码;这里只固定后续执行约束和启动提示。
## 方案选择
本次考虑三种方案:
1. 用户级 `systemd` 定时器 + 独立 Node/TypeScript 巡检脚本 + 本地批准入口。
2. 用户级 `systemd` 定时器 + Gitee 评论驱动批准。
3. 完全接入 Electron,使用应用内常驻进程和弹窗审批。
最终选择方案 1。
原因:
1. 它最小化对现有桌面应用逻辑的侵入,不要求应用常驻。
2. `systemd --user` 已符合你的运行环境偏好,也与仓库里已有的用户级后台命令模式一致。
3. 本地批准入口最容易落地,不依赖额外的 Gitee 写权限和 webhook/comment 解析。
4. 后续如果要升级成评论审批或 GUI 审批,也可以在该方案基础上扩展。
## 设计概览
新增一个独立的 issue 巡检子系统,由五部分组成:
1. `check-issues` 巡检入口:抓取 issue、打分、落本地状态。
2. `state` 状态层:保存当前候选项、历史批准记录和最近一次运行结果。
3. `approve-issue` 批准入口:由你手动触发,读取当前候选项并进入启动流程。
4. `opencode launcher`:负责拼接 issue prompt 并打开新的 opencode 窗口。
5. `systemd --user` 单元:负责每 6 小时调度巡检入口。
整体数据流分为两个阶段:
1. 自动巡检阶段:仅发现和记录,不启动修复。
2. 人工批准阶段:由你确认后,才启动新的 opencode 会话。
## 文件与模块边界
### 脚本入口
- 新增:`scripts/issue-bot/check-issues.ts`
- 负责单次巡检执行。
- 拉取 Gitee issues。
- 调用评分逻辑选出候选项。
- 写入状态文件和运行日志。
- 新增:`scripts/issue-bot/approve-issue.ts`
- 负责读取当前候选项。
- 检查是否已有未完成批准任务。
- 标记当前 issue 为已批准。
- 调用 opencode 启动器。
### 共享库
- 新增:`scripts/issue-bot/lib/gitee.ts`
- 封装 issue 列表获取与基础字段归一化。
- 输出统一结构,例如:`id``title``url``state``createdAt``updatedAt``labels``bodyPreview`
- 新增:`scripts/issue-bot/lib/ranking.ts`
- 根据“最新且最重要”的规则计算分数。
- 输出总分和评分明细,便于人工解释。
- 新增:`scripts/issue-bot/lib/state.ts`
- 负责本地状态读写。
- 处理状态文件缺失、损坏、备份与迁移。
- 新增:`scripts/issue-bot/lib/opencode.ts`
- 负责生成发给 opencode 的 prompt。
- 负责调用本地 opencode 启动命令。
- 固定写入 worktree 执行约束。
### 配置与调度
- 新增:`extras/systemd/spark-store-issue-bot.service`
- 用户级一次性服务,执行单轮巡检。
- 新增:`extras/systemd/spark-store-issue-bot.timer`
- 每 6 小时触发一次 service。
- 修改:`package.json`
- 增加 `issue-bot:check`
- 增加 `issue-bot:approve`
## 本地状态模型
建议把状态文件写到用户目录下的缓存位置,而不是仓库内,避免污染工作区。
建议路径:`~/.cache/spark-store/issue-bot/state.json`
状态至少包含:
```ts
interface IssueBotState {
currentCandidate: RankedIssue | null;
approvedIssue: ApprovedIssue | null;
seenIssueIds: number[];
lastRunAt: string | null;
lastRunStatus: "idle" | "success" | "network-error" | "parse-error";
lastRunMessage: string | null;
}
```
其中:
1. `currentCandidate` 表示当前等待你批准的候选 issue。
2. `approvedIssue` 表示已经批准并已启动 opencode 的 issue,用于避免重复批准。
3. `seenIssueIds` 用于辅助去重,避免每轮都反复选择同一批低质量 issue。
4. `lastRun*` 用于排查巡检失败原因。
## Gitee 拉取策略
优先顺序如下:
1. 若存在可稳定使用的 Gitee API,则优先使用 API。
2. 若 API 受限或字段不足,则退回页面抓取。
无论采用哪种来源,`gitee.ts` 对外只暴露统一的 issue 数据结构,不把 HTML 解析细节传播到评分层和状态层。
抓取范围只包含:
1. 打开的 issue。
2. 当前仓库 `spark-store-project/spark-store`
3. 必需字段能提取成功的 issue。
如果本轮无法获取完整 issue 列表:
1. 记录错误。
2. 不覆盖现有 `currentCandidate`
3. 结束本轮执行,等待下次 timer。
## 排序与筛选规则
评分逻辑使用可解释的静态规则,不做黑盒决策。
### 基础过滤
先过滤掉以下 issue
1. 已关闭 issue。
2. 已批准且尚未被显式清理的 issue。
3. 缺少标题或链接等关键字段的异常项。
### 加分项
以下情况加分:
1. 标题或内容包含高影响关键词:`崩溃``打不开``无法安装``升级失败``卡死``白屏``闪退`
2. 与主流程强相关:安装、卸载、更新、启动、搜索、列表加载。
3. 最近创建或最近更新。
4. 含有复现步骤、日志、截图、错误信息。
5. 带有明显 bug 类型标签。
### 减分项
以下情况减分:
1. 纯咨询类或需求讨论类 issue。
2. 信息过少,例如只有一句“不能用”。
3. 明显重复、无明确可执行内容。
### 产出格式
`ranking.ts` 输出不只包含总分,还包含明细,例如:
```ts
interface RankingBreakdown {
total: number;
reasons: string[];
}
```
状态文件和批准前摘要都需要携带这些明细,确保“为什么选它”是透明的。
## 巡检流程
`check-issues.ts` 的单轮行为固定为:
1. 读取本地状态。
2. 拉取 Gitee issue 列表。
3. 标准化数据。
4. 按过滤规则剔除不可处理项。
5. 计算每个 issue 的分数。
6. 选出得分最高的 1 个 issue。
7. 将其写入 `currentCandidate`
8. 更新 `lastRunAt``lastRunStatus` 和摘要信息。
如果没有候选项:
1.`currentCandidate` 设为 `null`
2. 写入“本轮无可处理 issue”的状态。
3. 不触发任何后续动作。
## 批准流程
`approve-issue.ts` 的行为固定为:
1. 读取本地状态。
2. 检查 `currentCandidate` 是否存在。
3. 检查是否已有 `approvedIssue` 正在等待处理结果。
4. 若可批准,则将候选项复制到 `approvedIssue`
5. 调用 opencode 启动器。
6. 启动成功后保留 `approvedIssue`,并可选择清空 `currentCandidate`
本次实现采用保守策略:
1. 启动成功后,清空 `currentCandidate`
2. 保留 `approvedIssue`,避免同一 issue 被重复批准。
后续如果需要“已完成”或“已放弃”清理动作,可以再补一个独立命令。
## Opencode 启动器设计
`opencode.ts` 负责两件事:
1. 生成 prompt。
2. 调用本地 opencode 启动命令。
### Prompt 内容
prompt 需要至少包含:
1. issue 标题。
2. issue URL。
3. issue 摘要。
4. 评分原因。
5. 任务目标:分析根因并开始修复。
6. 明确约束:开始修改时,基仓库使用 `~/Desktop/spark-store`,但实际编码必须通过 git worktree,从 `Erotica` 分支开出新分支后进行。
### Worktree 约束
批准后启动的新 opencode 会话中,必须显式看到以下执行约束:
1. 基仓库固定为 `~/Desktop/spark-store`
2. 真正开始修改代码前,使用 git worktree 创建隔离工作区。
3. 新 worktree 必须从 `Erotica` 分支开出新的工作分支。
4. 修复工作在该 worktree 中进行,而不是直接在主仓库工作目录中进行。
这里的职责是“把约束传给后续修复会话”,而不是在当前巡检脚本里代替用户创建 worktree。
### 启动命令配置
不要把 opencode 启动命令硬编码成不可修改的固定路径。
推荐顺序:
1. 读取环境变量,例如 `SPARK_STORE_OPENCODE_CMD`
2. 若未配置,则退回默认命令模板。
3. 若命令不存在,返回明确错误并保留 `currentCandidate`/`approvedIssue` 状态供重试。
## systemd 调度设计
使用用户级 systemd 单元:
### `spark-store-issue-bot.service`
职责:
1. 调用一次 `issue-bot:check`
2. 以 oneshot 形式运行。
3. 将日志交给 systemd journal。
### `spark-store-issue-bot.timer`
职责:
1. 每 6 小时触发一次 service。
2. 启用持久化调度,使设备休眠后恢复时仍可补跑。
不把批准动作放进 timer,因为批准必须由人工触发。
## 错误处理
### 网络或解析失败
1. 记录 `lastRunStatus` 为失败类型。
2. 保留旧候选项,不清空有效状态。
3. 输出清晰日志,供 `journalctl --user` 排查。
### 状态文件损坏
1. 读取失败时先备份原文件。
2. 生成新的空状态。
3. 在日志中注明发生了状态恢复。
### 启动 opencode 失败
1. 不丢失候选 issue 信息。
2. 记录失败信息到状态文件。
3. 允许你修正环境后再次执行批准或重试命令。
## 测试与验证
### 脚本层验证
需要至少覆盖以下行为:
1. 有多个 issue 时,能按规则稳定选出得分最高的候选项。
2. 无 issue 或全被过滤时,`currentCandidate` 正确为空。
3. 状态文件缺失时能初始化默认状态。
4. 状态文件损坏时能备份并恢复。
5. 批准入口能读取候选项并更新状态。
6. opencode 启动命令缺失时,能返回明确错误而不丢状态。
### 手动验证
需要人工验证:
1. `npm run issue-bot:check` 能成功写出候选项。
2. 连续运行两次巡检,状态更新符合预期,没有异常重复。
3. `npm run issue-bot:approve` 能基于当前候选项启动新的 opencode 窗口。
4. 启动后的 prompt 中包含 worktree 约束和 `Erotica` 分支要求。
5. `systemctl --user start spark-store-issue-bot.service` 可执行。
6. `systemctl --user enable --now spark-store-issue-bot.timer` 后能看到 timer 生效。
### 仓库质量验证
完成实现后,至少执行:
1. `npm run lint`
2. `npm run build:vite`
如果脚本新增了独立测试,还要运行相应测试命令。
## 风险与约束
1. Gitee 页面结构可能变化,因此 `gitee.ts` 需要把抓取逻辑局部化,避免影响其他模块。
2. “最重要”本质上是启发式规则,不保证绝对正确,因此必须保留人工批准环节。
3. 如果 opencode 的命令行接口或窗口启动方式在本机环境中变化,需要通过配置而不是源码硬编码来适配。
4. worktree 约束属于后续修复会话的执行要求,当前设计只负责传达和固化,不负责提前改变用户当前工作区。
## 决策总结
1.`systemd --user` 定时器每 6 小时巡检一次 Gitee issues。
2. 每轮只选 1 个“最新且最重要”的候选 issue。
3. 默认只汇报,不自动修复。
4. 你批准后,再自动拉起新的 opencode 窗口。
5. 启动 prompt 中必须固定写明:后续开始修改时,以 `~/Desktop/spark-store` 为基仓库,并通过 git worktree 从 `Erotica` 分支开新分支后执行修复。
@@ -0,0 +1,276 @@
# 已安装应用管理与更新中心加载态设计
## 背景
当前仓库里有三个直接影响体验的问题:
1. 更新中心调用 `updateCenterStore.open()` 时,会先等待主进程返回快照,再决定是否展示模态框。用户在数据返回前看不到任何反馈,主观感受就是“打开很慢”。
2. 软件管理里 `spark` 来源当前直接读取 `dpkg-query -W` 的全量安装包,结果混入了大量没有桌面入口的系统包,与“软件管理”应管理可见应用的预期不符。
3. 软件管理弹窗目前只有“卸载”操作,没有“打开”操作;同时 `src/App.vue``spark` 来源还有一条“若不在远端商店目录中则直接跳过”的过滤,会导致本机已有桌面应用即使后端已发现,也不会展示出来。
本次设计的目标是用最小改动修复这三个问题,不重做更新中心和软件管理的整体结构。
## 目标
1. 更新中心在用户触发打开时立即显示模态框,并展示明确的加载反馈。
2. `spark` 软件管理改为基于 `/usr/share/applications` 的桌面应用扫描,而不是全量系统包扫描。
3. `spark` 桌面应用通过 `realpath` 后的 desktop 文件路径,结合 `dpkg -S <desktop-path>` 反查所属包名。
4. `apm` 软件管理保持现有 `apm list --installed` 语义,继续展示依赖项。
5. 软件管理弹窗中的已安装项支持直接打开软件,复用当前已有的应用启动 IPC,而不是新增一套启动协议。
## 非目标
1. 不重构更新中心的主进程数据加载流程。
2. 不把软件管理改成“每个 desktop 入口一条记录”;本次仍按“每个包一条记录”展示。
3. 不改变 `apm` 来源中依赖项继续显示的现有产品决定。
4. 不新增应用启动器脚本,也不修改 `launch-app` IPC 的入参与调用协议。
5. 不把软件管理改造成新的独立模块或完整应用索引子系统。
## 方案概览
本次改动拆成三条最小链路:
1. 更新中心在渲染层增加独立加载态,让模态框先出现,再等待主进程快照。
2. `list-installed("spark")` 改为扫描 `/usr/share/applications` 并反查包名,再补齐版本、架构与图标信息。
3. 已安装应用弹窗增加“打开”按钮,并移除 `spark` 来源依赖远端商店目录的前端过滤,让本机已发现的桌面应用能够真正显示与启动。
## 更新中心加载态
### 当前问题
`src/App.vue` 中的 `openUpdateModal()` 直接 `await updateCenterStore.open()`,而 `src/modules/updateCenter.ts``open()` 会在拿到完整快照后才把 `isOpen` 设为 `true`。因此用户点击后会先经历一段无反馈等待。
### 目标行为
1. 用户触发打开更新中心时,模态框立即出现。
2. 数据尚未返回时,模态框主体显示“正在检查更新”的加载态,而不是空白区域。
3. 首次打开完成后,正常展示更新列表或错误提示。
4. 用户在已打开的更新中心里点击“刷新”时,继续使用同一加载状态字段,并禁用刷新按钮,避免重复触发。
### 设计
`src/modules/updateCenter.ts` 中为 `UpdateCenterStore` 新增渲染层加载状态,例如 `loading: Ref<boolean>`
行为规则:
1. `open()` 调用开始时:
- 先重置本次会话状态;
- 立即设置 `isOpen.value = true`
- 设置 `loading.value = true`
- 然后再等待 `window.updateCenter.open()`
2. `open()` 成功或失败结束时:
- 统一将 `loading.value = false`
3. `refresh()` 开始时:
- 设置 `loading.value = true`
- 调用 `window.updateCenter.refresh()`
- 完成后再恢复 `loading.value = false`
4. `closeNow()` 时:
- 关闭模态框;
- 清理搜索、选中项与迁移确认状态;
- 同时清理渲染层加载态,避免下次打开继承旧状态。
### UI 呈现
`src/components/UpdateCenterModal.vue` 负责根据 `store.loading.value` 切换内容:
1.`loading === true` 且还没有可展示项时,列表区域显示居中的加载卡片或 spinner,文案为“正在检查更新…”。
2.`loading === true` 且已有旧列表时,保留当前列表内容,同时在顶部或列表区域显示轻量的“正在刷新…”提示,避免刷新时内容闪烁清空。
3. `src/components/update-center/UpdateCenterToolbar.vue` 中的刷新按钮在 `loading === true` 时禁用,并可复用现有刷新图标做旋转或弱化处理。
这个方案只在渲染层加状态,不改主进程 `update-center-open` / `update-center-refresh` 的 IPC 协议,因此不会影响现有更新中心服务与测试边界。
## `spark` 软件管理的桌面应用扫描规则
### 当前问题
`electron/main/backend/install-manager.ts``list-installed("spark")` 目前直接跑:
```bash
dpkg-query -W -f=${Package} ${Version} ${Architecture}\n
```
它得到的是全量系统包,而不是用户可管理的桌面软件。
### 目标行为
`spark` 来源的软件管理只显示 `/usr/share/applications` 下可映射到系统包的桌面应用,每个包只展示一个条目。
### 扫描算法
主进程对 `spark` 来源执行以下流程:
1. 枚举 `/usr/share/applications` 目录中的 `.desktop` 文件。
2. 对每个候选文件执行 `realpath`,得到实际 desktop 路径,兼容软链接场景。
3. 读取 desktop 内容,解析:
- `Name`
- `Icon`
- `NoDisplay`
4. 过滤规则:
- 不是 `.desktop` 的文件直接跳过;
- `NoDisplay=true` 的 desktop 跳过;
- 无法读取、无法解析或 `realpath` 失败的条目跳过;
- `dpkg -S <realpath后的desktop路径>` 无法定位所属包名的条目跳过。
5. 对通过过滤的条目调用 `dpkg -S <desktop-path>` 反查所属包。
6. 将 desktop 条目按包名去重:
- 同一包命中多个有效 desktop 时,仅保留第一个有效条目;
- “第一个”的定义以稳定排序后的 desktop 文件名遍历顺序为准,保证结果可预测。
7. 收集到包名后,再补齐版本和架构信息,形成最终 `InstalledAppInfo[]`
### 包信息补齐
为了保留当前软件管理卡片里的版本与架构展示,`spark` 来源仍需要版本与架构信息,但不再以它作为筛选源。
推荐做法:
1. 先通过 desktop 扫描得到有效包名集合。
2. 再执行一次 `dpkg-query -W -f=${Package}\t${Version}\t${Architecture}\n` 构建元数据映射。
3. 仅为扫描结果中出现的包补齐 `version``arch`
这样保留了现有 UI 所需字段,同时避免再次回到“全量包即软件管理内容”的旧行为。
### 图标与名称
对于 `spark` 来源:
1. `name` 优先使用 desktop 的 `Name=`
2. `icon` 优先使用 desktop 的 `Icon=`;若图标字段是绝对路径,则延续现有 `file://` 使用方式;若是图标名,则允许继续走当前前端回退策略或显示默认占位。
3. `pkgname``dpkg -S` 反查出的包名为准,而不是 desktop 文件名。
### 错误处理
桌面应用扫描必须按“单项失败不拖垮整体列表”处理:
1. 某个 desktop 读取失败,只跳过该项。
2. 某个 desktop 无法反查包名,只跳过该项。
3. 只有当整个目录无法读取、或关键命令整体失败时,才返回 `success: false` 给渲染层。
## `apm` 软件管理保持现状
`apm` 来源继续使用当前 `apm list --installed` 结果,行为保持不变:
1. 仍保留依赖项展示。
2. 仍使用现有的 APM `entries/applications` 解析名称、图标与是否为依赖项。
3. 不把 `apm` 来源改成纯 desktop 视角。
这样可以满足“apm 包含依赖”的明确要求,同时把本次修改范围限制在 `spark` 侧软件识别逻辑。
## 渲染层已安装应用列表修正
### 当前问题
`src/App.vue``refreshInstalledApps()` 当前有一条 `spark` 特有过滤:
1. 先在远端商店应用列表 `apps.value` 中寻找同名应用;
2. 如果 `origin === "spark" && !appInfo`,则直接 `continue`
这会让许多本机桌面应用即使被主进程发现,也不会显示在软件管理中。
### 新规则
1. `refreshInstalledApps()``spark``apm` 统一采用“远端有完整信息则复用,远端没有则构造最小 App 对象”的策略。
2. 删除 `spark` 来源的“找不到远端目录就跳过”逻辑。
3. 这样主进程发现的本机桌面应用,无论是否存在于远端商店分类 JSON 中,都能在软件管理中展示出来。
### 最小 App 对象
当远端列表中找不到对应应用时,继续构造最小 `App` 对象,并补齐以下关键字段:
1. `name`
2. `pkgname`
3. `version`
4. `origin`
5. `currentStatus: "installed"`
6. `arch`
7. `flags`
8. `isDependency`
9. `icons`(如主进程提供)
其他目录型字段继续使用当前最小占位值即可,不额外扩展模型。
## 软件管理“打开软件”交互
### 目标行为
已安装应用弹窗中的每一项都支持直接打开软件,且不影响现有“卸载”入口。
### 交互设计
`src/components/InstalledAppsModal.vue` 中每个应用项新增一个 `打开` 按钮:
1. 点击“打开”时向父组件发出 `open-app` 事件,并透传:
- `pkgname`
- `origin`
2. “卸载”按钮保留。
3. 对于没有可启动信息的项,不新增额外灰态逻辑,因为本次两侧都沿用包名启动;只要条目被纳入软件管理,就认为可以尝试启动。
### 启动链路
继续复用当前已有 IPC`launch-app`
1. `spark` 来源继续执行:
- `/opt/spark-store/extras/app-launcher start <pkgname>`
2. `apm` 来源继续执行:
- `apm launch <pkgname>`
这个 IPC 已被下载详情与应用详情页复用,因此本次不改协议,只把软件管理接入同一入口。
## 模块影响范围
### 主进程
1. `electron/main/backend/install-manager.ts`
- 调整 `list-installed("spark")` 的发现逻辑。
- 可按需要抽出一个小型 helper 处理 spark desktop 扫描,避免继续堆大单文件。
### 渲染层状态与页面
1. `src/modules/updateCenter.ts`
- 新增加载态,并调整 `open()` / `refresh()` / `closeNow()` 的时序。
2. `src/components/UpdateCenterModal.vue`
- 根据加载态展示“正在检查更新”或“正在刷新”提示。
3. `src/components/update-center/UpdateCenterToolbar.vue`
- 刷新按钮支持禁用与加载视觉状态。
4. `src/components/InstalledAppsModal.vue`
- 新增“打开”按钮与 `open-app` 事件。
5. `src/App.vue`
- 打开更新中心时不再等待模态框延迟出现。
- 修正 `spark` 来源软件列表的远端目录过滤。
- 将软件管理中的 `open-app` 事件接到现有 `openDownloadedApp()`
## 测试策略
### 更新中心
扩展以下测试:
1. `src/__tests__/unit/update-center/store.test.ts`
- 覆盖 `open()` 在等待快照期间就已将 `isOpen` 置为 `true`
- 覆盖 `loading``open()``refresh()` 生命周期中的变化。
2. `src/__tests__/unit/update-center/UpdateCenterModal.test.ts`
- 覆盖加载态文案展示。
- 覆盖刷新按钮在加载时被禁用。
### 软件管理
1.`spark` desktop 扫描逻辑新增单元测试,覆盖:
-`/usr/share/applications` 发现有效 desktop
- 通过 `realpath + dpkg -S` 反查包名;
- 跳过 `NoDisplay=true`
- 同包多个 desktop 仅保留一个;
- 单个 desktop 失败不会让整批结果失败。
2. 扩展 `src/__tests__/unit/InstalledAppsModal.test.ts`
- 覆盖“打开”按钮可见;
- 覆盖点击后会发出 `open-app` 事件。
### 回归验证
1. `spark` 来源软件管理仍可卸载。
2. `apm` 来源软件管理仍保留依赖项显示。
3. 下载详情与应用详情页已有的 `launch-app` 调用不受影响。
## 风险与约束
1. `dpkg -S` 输出格式可能包含架构后缀或多条匹配结果,解析时需要明确采用“第一条所有权记录”的稳定策略,并只提取包名部分。
2. 某些 desktop 图标可能是主题图标名而非绝对路径;本次不重做图标解析,只保证名称与路径被正确透传。
3. 如果某些本机桌面应用没有远端商店元数据,软件管理中会显示最小信息卡片;这是预期结果,因为需求本身就是“以本机 `/usr/share/applications` 为准”。
4. 更新中心加载态只解决“无反馈等待”的问题,不保证主进程真实查询耗时本身缩短。
@@ -0,0 +1,89 @@
# Installed Apps Modal Actions Design
## Background
The installed-apps modal currently renders each installed app row with display information and an uninstall button only. It no longer exposes any path to launch an installed app or open that app's detail modal.
As a result:
1. Users cannot launch apps from the installed-apps manager.
2. Clicking apps that are already listed in the store no longer opens their detail view from that manager.
The parent app already has working handlers for both behaviors:
- `openDownloadedApp(pkgname, origin)` for launching
- `openDetail(app)` for showing app details
The regression is therefore in the modal interaction layer rather than in the launch backend itself.
## Goals
1. Restore a direct “open app” action in the installed-apps modal.
2. Restore a “view details” action for installed apps that can be matched to store detail data.
3. Reuse the existing parent handlers instead of creating a second launch/detail path.
4. Keep uninstall behavior unchanged.
5. Keep the change local to the installed-apps modal and its parent wiring.
## Non-Goals
1. Do not redesign the whole installed-apps UI.
2. Do not change uninstall flow.
3. Do not add a brand new launcher backend.
4. Do not change app-detail modal behavior itself.
## Recommended Approach
Add two explicit actions to each installed-app row:
1. `打开` - always available for installed apps, routed to the existing launch handler.
2. `查看详情` - available only when the app has enough store metadata to open a meaningful detail modal.
The modal emits these actions upward, and `App.vue` wires them to the existing parent methods. This restores behavior with minimal code movement and avoids duplicating launch or detail logic.
## UI Behavior
### Open action
- Every installed app row gets an `打开` button.
- Clicking it emits the installed app object upward.
- The parent maps this to `openDownloadedApp(app.pkgname, app.origin)`.
### Detail action
- Installed apps that can be resolved to a store-backed detail view get a `查看详情` button.
- The modal should treat an app as detail-capable when its data is sufficient for the existing `openDetail` path, specifically when:
- it has a non-`unknown` category, or
- it already carries enough store-backed fields to be opened meaningfully by the current parent logic.
- Clicking it emits the app upward.
- The parent maps this to `openDetail(app)`.
### Uninstall action
- The existing `卸载` button remains unchanged.
## Event Contract
`InstalledAppsModal.vue` should expose two additional emits:
1. `open-app`
2. `open-detail`
`App.vue` should listen to both and route them to existing functions, not wrappers with new behavior.
## Data Flow
1. `refreshInstalledApps()` continues building the installed app list.
2. Each installed app row decides whether the detail action is available.
3. Modal emits the chosen action with the clicked app.
4. Parent receives the event and invokes the existing launch/detail flow.
## Testing
Add focused unit coverage for the modal:
1. It renders the `打开` button for installed items.
2. It renders the `查看详情` button only when the app is detail-capable.
3. It emits `open-app` when the open button is clicked.
4. It emits `open-detail` when the detail button is clicked.
The tests do not need to re-test the internals of `openDownloadedApp()` or `openDetail()`; they only need to prove the modal restores the event path correctly.
@@ -0,0 +1,91 @@
# Update Center No-APTSS Behavior Design
## Background
The Electron update center currently loads Spark (`aptss`) and APM updates together inside `electron/main/backend/update-center/index.ts`. The loader unconditionally runs Spark-side commands and Spark metadata enrichment, even on systems where `aptss` is not installed.
In that environment, the update center should not continue the Spark update path and surface command failures. Instead, Spark updates should be skipped cleanly while the APM path continues to work.
## Goals
1. When `aptss` is unavailable, the update center must not keep executing Spark update queries.
2. When `aptss` is unavailable but APM is available, the update center should still open and show APM updates.
3. Spark metadata loading must also be skipped when `aptss` is unavailable.
4. Missing `aptss` should not be surfaced as a fatal update-center error by itself.
5. Existing behavior should remain unchanged on systems where `aptss` is available.
## Non-Goals
1. Do not redesign the update-center service or UI.
2. Do not change notifier behavior in this task.
3. Do not change how APM updates are loaded.
4. Do not add a new settings toggle or user-facing prompt.
## Recommended Approach
Add a lightweight backend availability gate for the Spark branch at the start of `loadUpdateCenterItems()`.
If `aptss` is unavailable, treat the Spark source as absent rather than failed:
1. Skip the Spark upgradable query.
2. Skip the Spark installed-package query.
3. Skip Spark metadata enrichment.
4. Continue loading APM items normally.
This keeps the change local to the update-center backend and avoids reporting a missing Spark source as an error when the APM source can still provide valid updates.
## Data Flow Changes
### Current behavior
`loadUpdateCenterItems()` currently runs these in parallel:
1. Spark upgradable query
2. APM upgradable query
3. Spark installed query
4. APM installed query
Then it always attempts category/icon/metadata enrichment for both source lists.
### New behavior
Before starting source queries, check whether `aptss` exists in `PATH`.
If available:
- Keep the existing Spark path unchanged.
If unavailable:
- Set Spark upgradable result to an empty successful result.
- Set Spark installed result to an empty successful result.
- Skip Spark metadata enrichment by passing an empty Spark item list forward.
APM loading remains unchanged in both cases.
## Error Handling
### Missing `aptss`
Missing `aptss` is treated as “Spark source not present”, not as “update center failed”.
That means:
- No fatal error is thrown solely because `aptss` is missing.
- No Spark warning is emitted just because `aptss` is absent.
- APM-only results are considered valid update-center output.
### Both sources unavailable or failing
If both Spark and APM are unavailable or both real source queries fail, the update center may continue to use the existing combined error path.
## Testing
Add a backend unit test covering this scenario:
1. `aptss` is unavailable.
2. APM upgradable and installed commands succeed.
3. Spark metadata command is never called.
4. `loadUpdateCenterItems()` returns APM items without throwing.
This test should prove the missing-`aptss` case is handled as a skip rather than an error.
@@ -0,0 +1,135 @@
# 更新忽略配置迁移设计
## 背景
Electron 更新中心已经具备忽略状态的数据通路,但默认仍写入 `/etc/spark-store/ignored_apps.conf`。老 Qt 更新器也沿用同一路径。新架构下更新器不再以 root 身份启动,因此 GUI 无法稳定写入 `/etc`。与此同时,`ss-update-notifier.sh` 以 root systemd 服务运行,若直接使用 `~/` 会错误落到 `/root`
本次改动的目标是在不重做更新链路的前提下,把“忽略更新”统一改为用户级配置,并让 Electron、老 Qt 更新器和 notifier 对同一份规则生效。
## 目标
1. 忽略配置统一迁移到用户目录 `~/.config/spark-store/ignored_apps.conf`
2. Electron 更新中心支持显式忽略和取消忽略操作。
3. 老 Qt 更新器改为读写同一份用户级忽略配置。
4. `ss-update-notifier.sh` 在 root systemd 环境下也能读取用户级忽略配置。
5. 忽略规则同时作用于 Spark 与 APM 更新项。
6. 忽略规则按 `pkgname|version` 精确匹配,被忽略的旧版本在后续出现新版本时应重新提醒。
## 非目标
1. 不兼容旧的 `/etc/spark-store/ignored_apps.conf`
2. 不改变更新下载、安装和迁移逻辑。
3. 不把忽略配置升级为 JSON 或数据库格式。
4. 不修改 AmberPM 侧的 `amber-pm-upgrade-notifier`
## 方案概览
本次实现由三部分组成:
1. Electron 主进程把忽略配置路径切到用户目录,渲染层补齐“忽略 / 取消忽略”入口,并把已忽略项排在后面展示。
2. 老 Qt 更新器的 `IgnoreConfig` 改为使用 `QStandardPaths::ConfigLocation` 下的 `spark-store/ignored_apps.conf`,同时将忽略键统一为“包名 + 新版本”。
3. `ss-update-notifier.sh` 新增用户配置定位与扫描逻辑,在 root systemd 环境下优先识别活动桌面用户,失败时回退扫描 `/home/*/.config/spark-store/ignored_apps.conf` 并合并忽略集合。
## 配置文件设计
### 路径
- 统一路径:`~/.config/spark-store/ignored_apps.conf`
- Electron 通过当前进程用户的 home 解析该路径。
- Qt 通过 `QStandardPaths::writableLocation(QStandardPaths::ConfigLocation)` 解析该路径。
- notifier 不直接依赖 `~/`,而是根据目标 home 拼出 `<home>/.config/spark-store/ignored_apps.conf`
### 格式
继续沿用现有纯文本格式,每行一条:
```text
pkgname|version
```
其中 `version` 统一表示“待更新到的新版本”,而不是当前已安装版本。
### 匹配语义
1. 仅当 `pkgname``version` 同时匹配时,视为被忽略。
2. 忽略规则不区分 `spark` / `apm` 来源。相同包名与目标版本的更新,在两侧都应被同一条规则命中。
3. 某版本被忽略后,未来出现更高版本时,不自动继承忽略状态。
## Electron 更新中心
### 主进程
`electron/main/backend/update-center/ignore-config.ts` 保持文本解析逻辑不变,只修改默认配置路径到用户目录。
`electron/main/backend/update-center/service.ts` 的默认读写也改用新路径,并在刷新结果上做一次稳定排序:
1. 正常更新项在前。
2. 已忽略项在后。
3. 同组内保持原有顺序,避免不必要的 UI 抖动。
### 渲染层交互
更新中心列表项新增两个互斥操作:
1. 未忽略项显示“忽略”按钮。
2. 已忽略项显示“取消忽略”按钮。
交互规则:
1. 点击“忽略”后调用 `window.updateCenter.ignore({ packageName, newVersion })`
2. 点击“取消忽略”后调用 `window.updateCenter.unignore({ packageName, newVersion })`
3. 主进程刷新完成后,渲染层使用推送或返回的新快照更新列表。
4. 已忽略项继续不可勾选,也不会加入“更新选中”任务。
## 老 Qt 更新器
### 配置路径
`IgnoreConfig` 不再尝试写 `/etc`,改为:
1. 使用 `QStandardPaths::writableLocation(QStandardPaths::ConfigLocation)`
2. 在其下创建 `spark-store/ignored_apps.conf`
### 忽略键统一
Qt 当前交互里,忽略按钮传的是当前版本,检查时也匹配当前版本。这会导致与 Electron 的“目标版本忽略”语义不一致。
本次统一改为:
1. 点击“忽略”时写入 `packageName + newVersion`
2. 刷新列表时,用 `packageName + newVersion` 判断是否忽略。
取消忽略也改为按包名 + 版本删除对应条目,避免误删同包历史忽略记录。
## `ss-update-notifier.sh`
### 读取忽略配置
脚本新增两个步骤:
1. 尝试定位最可能的桌面用户 home。
2. 如果无法可靠定位,则扫描 `/home/*/.config/spark-store/ignored_apps.conf`
扫描模式下需要把所有命中的配置文件合并成一个忽略集合,再参与过滤。
### 过滤规则
脚本当前只按包名过滤,本次改为按 `pkgname|newVersion` 精确过滤:
1.`ss-do-upgrade-worker.sh upgradable-list` 读取 `PKG_NAME PKG_NEW_VER PKG_CUR_VER`
2. 构造键 `PKG_NAME|PKG_NEW_VER`
3. 若忽略集合中存在该键,则跳过通知计数。
### 与通知用户识别解耦
通知发送仍然尽量复用现有“找活动用户然后 `sudo -u` 发送”的策略,但“读取忽略配置”与“给谁发通知”必须解耦:
1. 即使没有可靠的当前登录用户,也应先完成忽略过滤。
2. 只有在最终需要发送通知时,再尝试解析实际桌面用户。
## 验证范围
1. Electron 单元测试覆盖新路径常量、忽略排序与忽略按钮交互。
2. Electron 手动验证更新中心忽略 / 取消忽略流程。
3. Qt 手动验证忽略后重新打开更新器仍保留状态。
4. 手动执行 `ss-update-notifier.sh`,验证 root 环境下能命中用户级忽略配置且按版本精确过滤。
@@ -0,0 +1,129 @@
# Update Notifier APM Aggregation Design
## Background
`tool/update-upgrade/ss-update-notifier.sh` currently counts Spark (`aptss`) updates, filters them through `hold` state and `~/.config/spark-store/ignored_apps.conf`, then sends one desktop notification. A separate APM-side notifier pattern exists, but it is not merged into the current Spark notifier script.
The goal is to let the current notifier aggregate both Spark and APM upgradable items into one notification, while keeping the existing user-level ignored-update behavior and avoiding hard failures on systems that do not provide `aptss`.
## Goals
1. Keep a single notifier script: `tool/update-upgrade/ss-update-notifier.sh`.
2. Count both Spark and APM upgradable applications in that script.
3. Continue to use one shared ignored-update file: `~/.config/spark-store/ignored_apps.conf`.
4. Apply ignored filtering to both Spark and APM using exact `pkgname|newVersion` keys.
5. Apply `hold` filtering independently for Spark and APM.
6. Aggregate the remaining Spark and APM counts into one notification.
7. If `aptss` is unavailable, skip the Spark branch without failing the script.
## Non-Goals
1. Do not create a second notifier service or script.
2. Do not change the ignored-update file format.
3. Do not change Electron or update-center UI behavior in this task.
4. Do not add a compatibility layer for `/etc/spark-store/ignored_apps.conf`.
## Recommended Approach
Extend the existing notifier in place and keep Spark and APM as two counting branches inside the same script.
Spark keeps its current `aptss`-based flow. APM adds a second branch that parses `apm list --upgradable`, applies APM `hold` detection via `amber-pm-debug dpkg-query`, and reuses the same ignored-entry set already loaded from user config files. The final notification count becomes `spark_count + apm_count`.
This keeps the script small, preserves the current Spark path, and avoids introducing a second source of notification truth.
## Data Sources
### Spark branch
- Command availability gate: `command -v aptss`
- Refresh commands: `aptss update`, `LANGUAGE=en_US aptss ssupdate`
- Upgradable list source: `/opt/durapps/spark-store/bin/update-upgrade/ss-do-upgrade-worker.sh upgradable-list`
- Hold check: `dpkg-query -W -f='${db:Status-Want}' <pkg>`
### APM branch
- Command availability gate: `command -v apm`
- Refresh commands: `LANGUAGE=en_US apm update`, followed by `apm clean`
- Upgradable list source: `env LANGUAGE=en_US apm list --upgradable`
- Output compatibility: support both `[upgradable from: <version>]` and legacy `[from: <version>]` variants when extracting the current version
- Hold check: `amber-pm-debug dpkg-query -W -f='${db:Status-Want}' <pkg>`
## Filtering Rules
### Ignored entries
The script continues to load ignored entries from `~/.config/spark-store/ignored_apps.conf`, using the existing user-detection plus `/home/*` scan behavior.
Each valid line is still interpreted as:
```text
pkgname|version
```
Matching rule:
- Spark item is ignored when `pkgname|sparkNewVersion` exists in the ignored set.
- APM item is ignored when `pkgname|apmNewVersion` exists in the ignored set.
Ignored matching is intentionally source-agnostic. If Spark and APM expose the same package name and target version, one ignore entry suppresses both.
### Hold entries
- Spark item is excluded if `dpkg-query` reports `hold`.
- APM item is excluded if `amber-pm-debug dpkg-query` reports `hold`.
### Invalid or stale version entries
Each branch keeps its own version sanity check before counting:
- Spark continues to skip items where `newVersion <= currentVersion`.
- APM does the same after parsing `apm list --upgradable` output from either supported bracket variant.
## Availability Rules
### Missing `aptss`
If `aptss` is not installed or not in `PATH`:
1. Skip Spark refresh commands entirely.
2. Skip Spark upgradable counting entirely.
3. Continue with APM counting if `apm` is available.
### Missing `apm`
If `apm` is not installed or not in `PATH`:
1. Skip APM refresh commands entirely.
2. Skip APM upgradable counting entirely.
3. Continue with Spark counting if `aptss` is available.
### Both unavailable
If both `aptss` and `apm` are unavailable, the script exits without sending a notification.
## Notification Behavior
The script sends one notification only when:
```text
spark_effective_count + apm_effective_count > 0
```
The notification remains a single desktop message. The implementation may update the wording to mention both Spark and APM updates, but the key requirement is one aggregated notification rather than separate per-source notifications.
## Implementation Boundaries
1. Keep the current `detect-notify-user` and ignored-config discovery logic.
2. Add APM parsing as a second source-specific helper path instead of rewriting the whole script.
3. Keep the shell implementation POSIX-compatible with the current Bash usage already present in the file.
4. Avoid changing unrelated installer or update-center code in this task.
## Verification
1. `bash -n tool/update-upgrade/ss-update-notifier.sh`
2. Manual dry-run reasoning for all four cases:
- Spark only
- APM only
- Spark + APM
- neither available
3. Confirm ignored entries suppress both branches via exact `pkg|newVersion` matching.
+156 -149
View File
@@ -1,4 +1,4 @@
import { BrowserWindow, dialog, ipcMain, WebContents } from "electron"; import { ipcMain, WebContents } from "electron";
import { spawn, ChildProcess, exec } from "node:child_process"; import { spawn, ChildProcess, exec } from "node:child_process";
import { promisify } from "node:util"; import { promisify } from "node:util";
import fs from "node:fs"; import fs from "node:fs";
@@ -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,23 +106,12 @@ const checkApmAvailable = async (): Promise<boolean> => {
return found; return found;
}; };
/** 提权执行 shell-caller aptss install apm 安装 APM,安装后需用户重启电脑 */ /** 检测本机是否具备 Spark/aptss 管理能力 */
const runInstallApm = async (superUserCmd: string): Promise<boolean> => { const checkSparkAvailable = async (): Promise<boolean> => {
const execCommand = superUserCmd || SHELL_CALLER_PATH; const { code, stdout } = await runCommandCapture("which", ["aptss"]);
const execParams = superUserCmd const found = code === 0 && stdout.trim().length > 0;
? [SHELL_CALLER_PATH, "aptss", "install", "apm"] if (!found) logger.info("未检测到 aptss 命令");
: [SHELL_CALLER_PATH, "aptss", "install", "apm"]; return found;
logger.info(`执行安装 APM: ${execCommand} ${execParams.join(" ")}`);
const { code, stdout, stderr } = await runCommandCapture(
execCommand,
execParams,
);
if (code !== 0) {
logger.error({ code, stdout, stderr }, "安装 APM 失败");
return false;
}
logger.info("安装 APM 完成");
return true;
}; };
const parseUpgradableList = (output: string) => { const parseUpgradableList = (output: string) => {
@@ -189,61 +196,23 @@ ipcMain.on("queue-install", async (event, download_json) => {
const execParams = []; const execParams = [];
const downloadDir = `/tmp/spark-store/download/${pkgname}`; const downloadDir = `/tmp/spark-store/download/${pkgname}`;
// APM 应用:若本机没有 apm 命令,弹窗提示并可选提权安装 APM(安装后需重启电脑) // APM 应用:若本机没有 apm 命令,通知前端弹窗引导安装 APM
if (origin === "apm") { if (origin === "apm") {
const hasApm = await checkApmAvailable(); const hasApm = await checkApmAvailable();
if (!hasApm) { if (!hasApm) {
const win = BrowserWindow.fromWebContents(webContents); webContents.send("trigger-apm-install-dialog");
const { response } = await dialog.showMessageBox(win ?? undefined, { webContents.send("install-complete", {
type: "question", id,
title: "需要安装 APM", success: false,
message: "此应用需要使用 APM 安装。", time: Date.now(),
detail: exitCode: -1,
"APM是星火应用商店的容器包管理器,安装APM后方可安装此应用,是否确认安装?", message: JSON.stringify({
buttons: ["确认", "取消"], message: "未安装 APM,无法继续安装此应用",
defaultId: 0, stdout: "",
cancelId: 1, stderr: "",
}),
}); });
if (response !== 0) { return;
webContents.send("install-complete", {
id,
success: false,
time: Date.now(),
exitCode: -1,
message: JSON.stringify({
message: "用户取消安装 APM,无法继续安装此应用",
stdout: "",
stderr: "",
}),
});
return;
}
const installApmOk = await runInstallApm(superUserCmd);
if (!installApmOk) {
webContents.send("install-complete", {
id,
success: false,
time: Date.now(),
exitCode: -1,
message: JSON.stringify({
message: "安装 APM 失败,请检查网络或权限后重试",
stdout: "",
stderr: "",
}),
});
return;
} else {
// 安装APM成功,提示用户已安装成功,需要重启后方可展示应用
await dialog.showMessageBox(win ?? undefined, {
type: "info",
title: "APM 安装成功",
message: "恭喜您,APM 已成功安装",
detail:
"恭喜您,APM 已成功安装!您的应用已在安装中~\n首次安装APM后,需要重启电脑后方可在启动器展示应用。您可在应用安装完毕后择机重启电脑\n若您需要立即使用应用,可在应用安装后先在应用商店中打开您的应用。",
buttons: ["确定"],
defaultId: 0,
});
}
} }
} }
@@ -421,6 +390,7 @@ async function processNextInQueue() {
const aria2Args = [ const aria2Args = [
`--dir=${downloadDir}`, `--dir=${downloadDir}`,
"--allow-overwrite=true", "--allow-overwrite=true",
"--async-dns=false",
"--summary-interval=1", "--summary-interval=1",
"--connect-timeout=10", "--connect-timeout=10",
"--timeout=15", "--timeout=15",
@@ -436,8 +406,8 @@ async function processNextInQueue() {
sendStatus("downloading"); sendStatus("downloading");
// 下载重试逻辑:每次超时时间递增,最多3次 // 下载重试逻辑:共10次,5次3秒,3次5秒,2次10秒
const timeoutList = [3000, 5000, 15000]; // 第一次3秒,第二次5秒,第三次15秒 const timeoutList = [3000, 3000, 3000, 3000, 3000, 5000, 5000, 5000, 10000, 10000];
let retryCount = 0; let retryCount = 0;
let downloadSuccess = false; let downloadSuccess = false;
@@ -699,31 +669,26 @@ ipcMain.handle("check-installed", async (_event, payload: any) => {
return isInstalled; return isInstalled;
} }
const checkScript = "/opt/spark-store/extras/check-is-installed"; // Spark: 使用 dpkg-query 检查安装状态
const { code, stdout } = await runCommandCapture("dpkg-query", [
"-W",
"-f=${Package}\\t${Status}\\n",
pkgname,
]);
// 首先尝试使用内置脚本 if (code === 0) {
if (fs.existsSync(checkScript)) { const line = stdout.trim();
const child = spawn(checkScript, [pkgname], { if (line) {
shell: false, const parts = line.split("\t");
env: process.env, if (parts.length >= 2) {
}); const status = parts[1].trim();
// 检查状态是否为 "install ok installed"
await new Promise<void>((resolve) => { if (status === "install ok installed") {
child.on("error", (err) => {
logger.error(`check-installed 脚本执行失败: ${err?.message || err}`);
resolve();
});
child.on("close", (code) => {
if (code === 0) {
isInstalled = true; isInstalled = true;
logger.info(`应用已安装 (脚本检测): ${pkgname}`); logger.info(`应用已安装 (dpkg检测): ${pkgname}`);
} }
resolve(); }
}); }
});
if (isInstalled) return true;
} }
return isInstalled; return isInstalled;
@@ -793,9 +758,38 @@ ipcMain.on("remove-installed", async (_event, payload) => {
ipcMain.handle( ipcMain.handle(
"list-installed", "list-installed",
async (_event, origin: "apm" | "spark" = "apm") => { async (
_event,
payload: { origin: "apm" | "spark"; pkgnameList?: string[] },
) => {
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;
@@ -809,9 +803,54 @@ ipcMain.handle(
}> = []; }> = [];
if (origin === "spark") { if (origin === "spark") {
// 如果提供了包名列表,只检查这些包的安装状态(优化版)
if (pkgnameList && pkgnameList.length > 0) {
logger.info(
`使用优化模式检查 ${pkgnameList.length} 个 Spark 包的安装状态`,
);
// 批量查询这些包的状态
// 注意:dpkg-query 在部分包不存在时也会返回非零码,但已找到的包会输出到 stdout
const { stdout, stderr } = await runCommandCapture("dpkg-query", [
"-W",
"-f=${Package}\\t${Version}\\t${Architecture}\\t${Status}\\n",
...pkgnameList,
]);
// 即使没有错误,也可能有警告信息输出到 stderr
if (stderr) {
logger.debug(`dpkg-query warnings: ${stderr}`);
}
const lines = stdout.split("\n");
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
const parts = trimmed.split("\t");
if (parts.length >= 4) {
const status = parts[3].trim();
// 只保留状态为 "install ok installed" 的包
if (status === "install ok installed") {
installedApps.push({
pkgname: parts[0],
name: parts[0],
version: parts[1],
arch: parts[2],
flags: "[installed]",
origin: "spark",
isDependency: false,
});
}
}
}
return { success: true, apps: installedApps };
}
// 回退到全量扫描模式(未提供包名列表时)
logger.info("使用全量扫描模式获取所有 Spark 已安装包");
const { code, stdout } = await runCommandCapture("dpkg-query", [ const { code, stdout } = await runCommandCapture("dpkg-query", [
"-W", "-W",
"-f=${Package} ${Version} ${Architecture}\\n", "-f=${Package}\\t${Version}\\t${Architecture}\\t${Status}\\n",
]); ]);
if (code !== 0) { if (code !== 0) {
@@ -827,17 +866,21 @@ ipcMain.handle(
for (const line of lines) { for (const line of lines) {
const trimmed = line.trim(); const trimmed = line.trim();
if (!trimmed) continue; if (!trimmed) continue;
const parts = trimmed.split(" "); const parts = trimmed.split("\t");
if (parts.length >= 3) { if (parts.length >= 4) {
installedApps.push({ const status = parts[3].trim();
pkgname: parts[0], // 只保留状态为 "install ok installed" 的包
name: parts[0], if (status === "install ok installed") {
version: parts[1], installedApps.push({
arch: parts[2], pkgname: parts[0],
flags: "[installed]", name: parts[0],
origin: "spark", version: parts[1],
isDependency: false, arch: parts[2],
}); flags: "[installed]",
origin: "spark",
isDependency: false,
});
}
} }
} }
return { success: true, apps: installedApps }; return { success: true, apps: installedApps };
@@ -985,52 +1028,16 @@ ipcMain.handle("check-apm-available", async () => {
return await checkApmAvailable(); return await checkApmAvailable();
}); });
ipcMain.handle("check-spark-available", async () => {
return await checkSparkAvailable();
});
// 显示 APM 安装对话框(在点击安装按钮时提前检查) // 显示 APM 安装对话框(在点击安装按钮时提前检查)
// 前端已改为 Vue 弹窗,此后端处理仅作为兜底
ipcMain.handle("show-apm-install-dialog", async (event) => { ipcMain.handle("show-apm-install-dialog", async (event) => {
const webContents = event.sender; const webContents = event.sender;
const win = BrowserWindow.fromWebContents(webContents); webContents.send("trigger-apm-install-dialog");
const superUserCmd = await checkSuperUserCommand(); return { success: false, cancelled: true };
const { response } = await dialog.showMessageBox(win ?? undefined, {
type: "question",
title: "需要安装 APM",
message: "此应用需要使用 APM 安装。",
detail:
"APM 是星火应用商店的软件包兼容工具,此应用使用星火 APM 提供支持,安装APM后方可安装此应用,是否确认安装?",
buttons: ["确认", "取消"],
defaultId: 0,
cancelId: 1,
});
if (response !== 0) {
return { success: false, cancelled: true };
}
const installApmOk = await runInstallApm(superUserCmd);
if (!installApmOk) {
await dialog.showMessageBox(win ?? undefined, {
type: "error",
title: "安装失败",
message: "安装 APM 失败",
detail: "请检查网络或权限后重试",
buttons: ["确定"],
defaultId: 0,
});
return { success: false, cancelled: false };
}
// 安装APM成功,提示用户已安装成功,需要重启后方可展示应用
await dialog.showMessageBox(win ?? undefined, {
type: "info",
title: "APM 安装成功",
message: "恭喜您,APM 已成功安装",
detail:
"恭喜您,APM 已成功安装!\n首次安装APM后,需要重启电脑后方可使用全部功能。您可在应用安装完毕后择机重启电脑。",
buttons: ["确定"],
defaultId: 0,
});
return { success: true, cancelled: false };
}); });
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
+4 -9
View File
@@ -2,7 +2,7 @@
* 共享的安装/下载逻辑 * 共享的安装/下载逻辑
* 被 install-manager.ts 和 update-center 共同使用 * 被 install-manager.ts 和 update-center 共同使用
*/ */
import { spawn, ChildProcess } from "node:child_process"; import { spawn } from "node:child_process";
import { createWriteStream } from "node:fs"; import { createWriteStream } from "node:fs";
import * as fs from "node:fs"; import * as fs from "node:fs";
import * as path from "node:path"; import * as path from "node:path";
@@ -34,7 +34,6 @@ export interface DownloadResult {
* 与 install-manager.ts 中的下载逻辑保持一致 * 与 install-manager.ts 中的下载逻辑保持一致
*/ */
export const downloadPackage = async ({ export const downloadPackage = async ({
pkgname,
metalinkUrl, metalinkUrl,
filename, filename,
downloadDir, downloadDir,
@@ -90,6 +89,7 @@ export const downloadPackage = async ({
const aria2Args = [ const aria2Args = [
`--dir=${downloadDir}`, `--dir=${downloadDir}`,
"--allow-overwrite=true", "--allow-overwrite=true",
"--async-dns=false",
"--summary-interval=1", "--summary-interval=1",
"--connect-timeout=10", "--connect-timeout=10",
"--timeout=15", "--timeout=15",
@@ -105,8 +105,8 @@ export const downloadPackage = async ({
onStatus?.("downloading"); onStatus?.("downloading");
// 下载重试逻辑:每次超时时间递增,最多3次 // 下载重试逻辑:共10次,5次3秒,3次5秒,2次10秒
const timeoutList = [3000, 5000, 15000]; const timeoutList = [3000, 3000, 3000, 3000, 3000, 5000, 5000, 5000, 10000, 10000];
let retryCount = 0; let retryCount = 0;
let downloadSuccess = false; let downloadSuccess = false;
@@ -225,7 +225,6 @@ export interface InstallOptions {
* 与 install-manager.ts 中的安装逻辑保持一致 * 与 install-manager.ts 中的安装逻辑保持一致
*/ */
export const installPackage = async ({ export const installPackage = async ({
pkgname,
filePath, filePath,
origin, origin,
superUserCmd, superUserCmd,
@@ -265,8 +264,6 @@ export const installPackage = async ({
env: process.env, env: process.env,
}); });
let stdout = "";
let stderr = "";
let logBuffer = ""; let logBuffer = "";
let logBufferTimer: NodeJS.Timeout | null = null; let logBufferTimer: NodeJS.Timeout | null = null;
const LOG_FLUSH_MS = 100; const LOG_FLUSH_MS = 100;
@@ -295,12 +292,10 @@ export const installPackage = async ({
signal?.addEventListener("abort", abortHandler, { once: true }); signal?.addEventListener("abort", abortHandler, { once: true });
child.stdout?.on("data", (data) => { child.stdout?.on("data", (data) => {
stdout += data.toString();
bufferedSendLog(data.toString()); bufferedSendLog(data.toString());
}); });
child.stderr?.on("data", (data) => { child.stderr?.on("data", (data) => {
stderr += data.toString();
bufferedSendLog(data.toString()); bufferedSendLog(data.toString());
}); });
@@ -1,5 +1,4 @@
import { join } from "node:path"; import { downloadPackage } from "../shared-installer";
import { downloadPackage, type DownloadResult } from "../shared-installer";
import type { UpdateCenterItem } from "./types"; import type { UpdateCenterItem } from "./types";
export interface Aria2DownloadResult { export interface Aria2DownloadResult {
@@ -1,9 +1,16 @@
import { mkdir, readFile, writeFile } from "node:fs/promises"; import { mkdir, readFile, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { dirname } from "node:path"; import { dirname } from "node:path";
import { join } from "node:path";
import type { UpdateCenterItem } from "./types"; import type { UpdateCenterItem } from "./types";
export const LEGACY_IGNORE_CONFIG_PATH = "/etc/spark-store/ignored_apps.conf"; export const IGNORE_CONFIG_PATH = join(
homedir(),
".config",
"spark-store",
"ignored_apps.conf",
);
const LEGACY_IGNORE_SEPARATOR = "|"; const LEGACY_IGNORE_SEPARATOR = "|";
@@ -77,3 +84,15 @@ export const applyIgnoredEntries = (
createIgnoreKey(item.pkgname, item.nextVersion), createIgnoreKey(item.pkgname, item.nextVersion),
), ),
})); }));
export const sortIgnoredItems = (
items: UpdateCenterItem[],
): UpdateCenterItem[] => {
return [...items].sort((left, right) => {
if (left.ignored === right.ignored) {
return 0;
}
return left.ignored === true ? 1 : -1;
});
};
+141 -37
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,
@@ -53,7 +54,7 @@ const APTSS_LIST_UPGRADABLE_COMMAND = {
command: "bash", command: "bash",
args: [ args: [
"-lc", "-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", "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 | awk 'NR>1'",
], ],
}; };
@@ -190,7 +191,14 @@ const loadAptssItemMetadata = async (
} }
const metadata = parsePrintUrisOutput(metadataResult.stdout); const metadata = parsePrintUrisOutput(metadataResult.stdout);
console.log(`[DEBUG] APTSS parsed metadata:`, metadata); if (metadata) {
console.log(`[DEBUG] APTSS parsed metadata:`, {
...metadata,
downloadUrl: `${metadata.downloadUrl}.metalink`,
});
} else {
console.log(`[DEBUG] APTSS parsed metadata:`, metadata);
}
if (!metadata) { if (!metadata) {
return { return {
@@ -349,61 +357,151 @@ 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 (
storeFilter: StoreFilter = "both",
runCommand: UpdateCenterCommandRunner = runCommandCapture, runCommand: UpdateCenterCommandRunner = runCommandCapture,
): Promise<UpdateCenterLoadItemsResult> => { ): Promise<UpdateCenterLoadItemsResult> => {
console.log(
`[UpdateCenter] loadUpdateCenterItems called with storeFilter=${storeFilter}`,
);
const [sparkEnabled, apmEnabled] = await Promise.all([
isSourceEnabled(storeFilter, "spark")
? isCommandAvailable(runCommand, "aptss")
: Promise.resolve(false),
isSourceEnabled(storeFilter, "apm")
? isCommandAvailable(runCommand, "apm")
: Promise.resolve(false),
]);
console.log(
`[UpdateCenter] sparkEnabled=${sparkEnabled}, apmEnabled=${apmEnabled}`,
);
const [aptssResult, apmResult, aptssInstalledResult, apmInstalledResult] = const [aptssResult, apmResult, aptssInstalledResult, apmInstalledResult] =
await Promise.all([ await Promise.all([
runCommand( sparkEnabled
APTSS_LIST_UPGRADABLE_COMMAND.command, ? runCommand(
APTSS_LIST_UPGRADABLE_COMMAND.args, APTSS_LIST_UPGRADABLE_COMMAND.command,
), APTSS_LIST_UPGRADABLE_COMMAND.args,
runCommand("apm", ["list", "--upgradable"]), )
runCommand( : Promise.resolve({ code: 0, stdout: "", stderr: "" }),
DPKG_QUERY_INSTALLED_COMMAND.command, apmEnabled
DPKG_QUERY_INSTALLED_COMMAND.args, ? runCommand("apm", ["list", "--upgradable"])
), : Promise.resolve({ code: 0, stdout: "", stderr: "" }),
runCommand("apm", ["list", "--installed"]), sparkEnabled
? runCommand(
DPKG_QUERY_INSTALLED_COMMAND.command,
DPKG_QUERY_INSTALLED_COMMAND.args,
)
: Promise.resolve({ code: 0, stdout: "", stderr: "" }),
apmEnabled
? runCommand("apm", ["list", "--installed"])
: Promise.resolve({ code: 0, stdout: "", stderr: "" }),
]); ]);
console.log(
`[UpdateCenter] aptssResult: code=${aptssResult.code}, stdout=${aptssResult.stdout.substring(0, 500)}, stderr=${aptssResult.stderr.substring(0, 500)}`,
);
console.log(
`[UpdateCenter] apmResult: code=${apmResult.code}, stdout=${apmResult.stdout.substring(0, 500)}, stderr=${apmResult.stderr.substring(0, 500)}`,
);
console.log(
`[UpdateCenter] aptssInstalledResult: code=${aptssInstalledResult.code}, stdout=${aptssInstalledResult.stdout.substring(0, 500)}`,
);
console.log(
`[UpdateCenter] apmInstalledResult: code=${apmInstalledResult.code}, stdout=${apmInstalledResult.stdout.substring(0, 500)}`,
);
const aptssAvailable =
sparkEnabled && (aptssResult.code === 0 || aptssInstalledResult.code === 0);
const warnings = [ const warnings = [
getCommandError("aptss upgradable query", aptssResult), aptssAvailable
getCommandError("apm upgradable query", apmResult), ? getCommandError("aptss upgradable query", aptssResult)
getCommandError("dpkg installed query", aptssInstalledResult), : null,
getCommandError("apm installed query", apmInstalledResult), apmEnabled ? getCommandError("apm upgradable query", apmResult) : null,
aptssAvailable
? getCommandError("dpkg installed query", aptssInstalledResult)
: null,
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 =
aptssResult.code === 0 aptssAvailable && aptssResult.code === 0
? parseAptssUpgradableOutput(aptssResult.stdout) ? parseAptssUpgradableOutput(aptssResult.stdout)
: []; : [];
const apmItems = const apmItems =
apmResult.code === 0 ? parseApmUpgradableOutput(apmResult.stdout) : []; apmEnabled && apmResult.code === 0
? parseApmUpgradableOutput(apmResult.stdout)
if (aptssResult.code !== 0 && apmResult.code !== 0) { : [];
throw new Error(warnings.join("; ")); console.log(
} `[UpdateCenter] parsed aptssItems count=${aptssItems.length}`,
aptssItems.map((i) => `${i.pkgname} ${i.currentVersion}->${i.nextVersion}`),
const installedSources = buildInstalledSourceMap( );
aptssInstalledResult.code === 0 ? aptssInstalledResult.stdout : "", console.log(
apmInstalledResult.code === 0 ? apmInstalledResult.stdout : "", `[UpdateCenter] parsed apmItems count=${apmItems.length}`,
apmItems.map((i) => `${i.pkgname} ${i.currentVersion}->${i.nextVersion}`),
); );
const installedSources = buildInstalledSourceMap(
aptssAvailable && aptssInstalledResult.code === 0
? aptssInstalledResult.stdout
: "",
apmInstalledResult.code === 0 ? apmInstalledResult.stdout : "",
);
console.log(`[UpdateCenter] installedSources size=${installedSources.size}`);
const [categorizedAptssItems, categorizedApmItems] = await Promise.all([ const [categorizedAptssItems, categorizedApmItems] = await Promise.all([
enrichItemCategories(aptssItems), aptssAvailable ? enrichItemCategories(aptssItems) : Promise.resolve([]),
enrichItemCategories(apmItems), apmEnabled ? enrichItemCategories(apmItems) : Promise.resolve([]),
]); ]);
const [enrichedAptssItems, enrichedApmItems] = await Promise.all([ const [enrichedAptssItems, enrichedApmItems] = await Promise.all([
enrichAptssItems(categorizedAptssItems, runCommand), aptssAvailable
enrichApmItems(categorizedApmItems, runCommand), ? enrichAptssItems(categorizedAptssItems, runCommand)
: Promise.resolve({ items: [], warnings: [] }),
apmEnabled
? enrichApmItems(categorizedApmItems, runCommand)
: Promise.resolve({ items: [], warnings: [] }),
]); ]);
console.log(
`[UpdateCenter] enrichedAptssItems: count=${enrichedAptssItems.items.length}, warnings=${enrichedAptssItems.warnings.length}`,
enrichedAptssItems.warnings,
);
console.log(
`[UpdateCenter] enrichedApmItems: count=${enrichedApmItems.items.length}, warnings=${enrichedApmItems.warnings.length}`,
enrichedApmItems.warnings,
);
const mergedItems = mergeUpdateSources(
enrichItemIcons(enrichedAptssItems.items),
enrichItemIcons(enrichedApmItems.items),
installedSources,
);
console.log(
`[UpdateCenter] mergedItems count=${mergedItems.length}`,
mergedItems.map(
(i) => `${i.pkgname} (${i.source}) ${i.currentVersion}->${i.nextVersion}`,
),
);
return { return {
items: mergeUpdateSources( items: mergedItems,
enrichItemIcons(enrichedAptssItems.items),
enrichItemIcons(enrichedApmItems.items),
installedSources,
),
warnings: [ warnings: [
...warnings, ...warnings,
...enrichedAptssItems.warnings, ...enrichedAptssItems.warnings,
@@ -426,8 +524,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),
+46 -12
View File
@@ -6,10 +6,9 @@ import type {
UpdateSource, UpdateSource,
} from "./types"; } from "./types";
const UPGRADABLE_PATTERN =
/^(\S+)\/\S+\s+([^\s]+)\s+\S+\s+\[(?:upgradable from|from):\s*([^\]]+)\]$/i;
const PRINT_URIS_PATTERN = /'([^']+)'\s+(\S+)\s+(\d+)\s+SHA512:([^\s]+)/; const PRINT_URIS_PATTERN = /'([^']+)'\s+(\S+)\s+(\d+)\s+SHA512:([^\s]+)/;
const APM_INSTALLED_PATTERN = /^(\S+)\/\S+(?:,\S+)?\s+\S+\s+\S+\s+\[[^\]]+\]$/; const APM_INSTALLED_PATTERN = /^(\S+)\/\S+(?:,\S+)?\s+\S+\s+\S+\s+\[[^\]]+\]$/;
const CURRENT_VERSION_PATTERN = /\[(?:upgradable from|from):\s*([^\]\s]+)\]/i;
const splitVersion = (version: string) => { const splitVersion = (version: string) => {
const epochMatch = version.match(/^(\d+):(.*)$/); const epochMatch = version.match(/^(\d+):(.*)$/);
@@ -190,18 +189,27 @@ const parseUpgradableOutput = (
const items: UpdateCenterItem[] = []; const items: UpdateCenterItem[] = [];
for (const line of output.split("\n")) { for (const line of output.split("\n")) {
const trimmed = line.trim(); const trimmed = line
if (!trimmed || trimmed.startsWith("Listing")) { .replace(
// eslint-disable-next-line no-control-regex
/\x1b\[[0-9;]*m/g,
"",
)
.trim();
if (!trimmed || trimmed.startsWith("Listing") || !trimmed.includes("/")) {
continue; continue;
} }
const match = trimmed.match(UPGRADABLE_PATTERN); const tokens = trimmed.split(/\s+/);
if (!match) { if (tokens.length < 3) {
continue; continue;
} }
const [, pkgname, nextVersion, currentVersion] = match; const pkgname = tokens[0]?.split("/")[0] ?? "";
const arch = trimmed.split(/\s+/)[2]; const nextVersion = tokens[1] ?? "";
const arch = tokens[2] ?? "";
const currentVersion =
trimmed.match(CURRENT_VERSION_PATTERN)?.[1] ?? tokens[5] ?? "";
if (!pkgname || nextVersion === currentVersion) { if (!pkgname || nextVersion === currentVersion) {
continue; continue;
} }
@@ -254,10 +262,29 @@ const compareVersions = (left: string, right: string): number => {
export const parseAptssUpgradableOutput = ( export const parseAptssUpgradableOutput = (
output: string, output: string,
): UpdateCenterItem[] => parseUpgradableOutput(output, "aptss"); ): UpdateCenterItem[] => {
console.log(
`[UpdateCenter] parseAptssUpgradableOutput input (first 1000 chars): ${output.substring(0, 1000)}`,
);
const result = parseUpgradableOutput(output, "aptss");
console.log(
`[UpdateCenter] parseAptssUpgradableOutput result count=${result.length}`,
);
return result;
};
export const parseApmUpgradableOutput = (output: string): UpdateCenterItem[] => export const parseApmUpgradableOutput = (
parseUpgradableOutput(output, "apm"); output: string,
): UpdateCenterItem[] => {
console.log(
`[UpdateCenter] parseApmUpgradableOutput input (first 1000 chars): ${output.substring(0, 1000)}`,
);
const result = parseUpgradableOutput(output, "apm");
console.log(
`[UpdateCenter] parseApmUpgradableOutput result count=${result.length}`,
);
return result;
};
export const parsePrintUrisOutput = ( export const parsePrintUrisOutput = (
output: string, output: string,
@@ -265,8 +292,15 @@ export const parsePrintUrisOutput = (
UpdateCenterItem, UpdateCenterItem,
"downloadUrl" | "fileName" | "size" | "sha512" "downloadUrl" | "fileName" | "size" | "sha512"
> | null => { > | null => {
const match = output.trim().match(PRINT_URIS_PATTERN); const trimmed = output.trim();
console.log(
`[UpdateCenter] parsePrintUrisOutput input (first 500 chars): ${trimmed.substring(0, 500)}`,
);
const match = trimmed.match(PRINT_URIS_PATTERN);
if (!match) { if (!match) {
console.log(
`[UpdateCenter] parsePrintUrisOutput: no match found for pattern ${PRINT_URIS_PATTERN}`,
);
return null; return null;
} }
+34 -9
View File
@@ -1,10 +1,11 @@
import { BrowserWindow } from "electron"; import { BrowserWindow } from "electron";
import { import {
LEGACY_IGNORE_CONFIG_PATH, IGNORE_CONFIG_PATH,
applyIgnoredEntries, applyIgnoredEntries,
createIgnoreKey, createIgnoreKey,
loadIgnoredEntries, loadIgnoredEntries,
saveIgnoredEntries, saveIgnoredEntries,
sortIgnoredItems,
} from "./ignore-config"; } from "./ignore-config";
import { import {
createUpdateCenterQueue, createUpdateCenterQueue,
@@ -12,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[];
@@ -67,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>;
@@ -80,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>;
} }
@@ -134,13 +139,14 @@ 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(LEGACY_IGNORE_CONFIG_PATH)); (() => loadIgnoredEntries(IGNORE_CONFIG_PATH));
const saveIgnored = const saveIgnored =
options.saveIgnoredEntries ?? options.saveIgnoredEntries ??
((entries: ReadonlySet<string>) => ((entries: ReadonlySet<string>) =>
saveIgnoredEntries(LEGACY_IGNORE_CONFIG_PATH, entries)); saveIgnoredEntries(IGNORE_CONFIG_PATH, entries));
const applyWarning = (message: string): void => { const applyWarning = (message: string): void => {
queue.finishRefresh([message]); queue.finishRefresh([message]);
@@ -156,19 +162,38 @@ export const createUpdateCenterService = (
return snapshot; return snapshot;
}; };
const refresh = async (): Promise<UpdateCenterServiceState> => { const refresh = async (
storeFilter: StoreFilter = currentStoreFilter,
): Promise<UpdateCenterServiceState> => {
currentStoreFilter = storeFilter;
console.log(
`[UpdateCenter] service.refresh called with storeFilter=${storeFilter}`,
);
queue.startRefresh(); queue.startRefresh();
emit(); emit();
try { try {
const ignoredEntries = await loadIgnored(); const ignoredEntries = await loadIgnored();
const loadedItems = normalizeLoadedItems(await options.loadItems()); console.log(`[UpdateCenter] ignoredEntries count=${ignoredEntries.size}`);
const items = applyIgnoredEntries(loadedItems.items, ignoredEntries); const loadedItems = normalizeLoadedItems(
await options.loadItems(currentStoreFilter),
);
console.log(
`[UpdateCenter] loadItems returned: items=${loadedItems.items.length}, warnings=${loadedItems.warnings.length}`,
loadedItems.warnings,
);
const items = sortIgnoredItems(
applyIgnoredEntries(loadedItems.items, ignoredEntries),
);
console.log(
`[UpdateCenter] after applying ignored: items=${items.length}`,
);
queue.setItems(items); queue.setItems(items);
queue.finishRefresh(loadedItems.warnings); queue.finishRefresh(loadedItems.warnings);
return emit(); return emit();
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
console.error(`[UpdateCenter] refresh error:`, error);
queue.setItems([]); queue.setItems([]);
applyWarning(message); applyWarning(message);
return emit(); return emit();
+97 -12
View File
@@ -98,13 +98,18 @@ logger.info("User Agent: " + getUserAgent());
/** 根据启动参数 --no-apm / --no-spark 决定只展示的来源 */ /** 根据启动参数 --no-apm / --no-spark 决定只展示的来源 */
function getStoreFilterFromArgv(): "spark" | "apm" | "both" { function getStoreFilterFromArgv(): "spark" | "apm" | "both" {
const argv = process.argv; if (process.arch === "loong64") {
const noApm = argv.includes("--no-apm"); // Currently loong64 only have spark support
const noSpark = argv.includes("--no-spark"); return "spark";
if (noApm && noSpark) return "both"; } else {
if (noApm) return "spark"; const argv = process.argv;
if (noSpark) return "apm"; const noApm = argv.includes("--no-apm");
return "both"; const noSpark = argv.includes("--no-spark");
if (noApm && noSpark) return "both";
if (noApm) return "spark";
if (noSpark) return "apm";
return "both";
}
} }
ipcMain.handle("get-store-filter", (): "spark" | "apm" | "both" => ipcMain.handle("get-store-filter", (): "spark" | "apm" | "both" =>
@@ -214,21 +219,101 @@ ipcMain.on("set-theme-source", (event, theme: "system" | "light" | "dark") => {
nativeTheme.themeSource = theme; nativeTheme.themeSource = theme;
}); });
// 启动安装设置脚本(可能需要提升权限) // 配置文件路径
ipcMain.handle("open-install-settings", async () => { const SPARK_CONFIG_DIR = path.join(
os.homedir(),
".config/spark-union/spark-store",
);
const UPDATE_CHECK_CONFIG = "ssshell-config-do-not-show-upgrade-notify";
const CREATE_DESKTOP_CONFIG = "ssshell-config-do-not-create-desktop";
// 获取安装设置
ipcMain.handle("get-install-settings", async () => {
try {
const result: Record<string, boolean> = {};
// 检查更新检测配置
result[UPDATE_CHECK_CONFIG] = fs.existsSync(
path.join(SPARK_CONFIG_DIR, UPDATE_CHECK_CONFIG),
);
// 检查自动创建桌面启动器配置
result[CREATE_DESKTOP_CONFIG] = fs.existsSync(
path.join(SPARK_CONFIG_DIR, CREATE_DESKTOP_CONFIG),
);
return { success: true, data: result };
} catch (err) {
logger.error({ err }, "Failed to get install settings");
return { success: false, message: (err as Error)?.message || String(err) };
}
});
// 设置安装设置
ipcMain.handle(
"set-install-settings",
async (
_event,
settings: {
[UPDATE_CHECK_CONFIG]?: boolean;
[CREATE_DESKTOP_CONFIG]?: boolean;
},
) => {
try {
// 确保配置目录存在
if (!fs.existsSync(SPARK_CONFIG_DIR)) {
fs.mkdirSync(SPARK_CONFIG_DIR, { recursive: true });
}
// 更新检测配置
const updateCheckPath = path.join(SPARK_CONFIG_DIR, UPDATE_CHECK_CONFIG);
if (settings[UPDATE_CHECK_CONFIG]) {
fs.writeFileSync(updateCheckPath, "");
} else {
if (fs.existsSync(updateCheckPath)) {
fs.unlinkSync(updateCheckPath);
}
}
// 自动创建桌面启动器配置
const createDesktopPath = path.join(
SPARK_CONFIG_DIR,
CREATE_DESKTOP_CONFIG,
);
if (settings[CREATE_DESKTOP_CONFIG]) {
fs.writeFileSync(createDesktopPath, "");
} else {
if (fs.existsSync(createDesktopPath)) {
fs.unlinkSync(createDesktopPath);
}
}
return { success: true };
} catch (err) {
logger.error({ err }, "Failed to set install settings");
return {
success: false,
message: (err as Error)?.message || String(err),
};
}
},
);
// 检查更新
ipcMain.handle("check-for-updates", async () => {
try { try {
const { spawn } = await import("node:child_process"); const { spawn } = await import("node:child_process");
const scriptPath = const scriptPath =
"/opt/durapps/spark-store/bin/update-upgrade/ss-update-controler.sh"; "/opt/durapps/spark-store/bin/update-upgrade/ss-do-upgrade.sh";
const child = spawn("systemd-run", ["--user", scriptPath], { const child = spawn("systemd-run", ["--user", scriptPath], {
detached: true, detached: true,
stdio: "ignore", stdio: "ignore",
}); });
child.unref(); child.unref();
logger.info(`Launched ${scriptPath}`); logger.info(`Launched update check script: ${scriptPath}`);
return { success: true }; return { success: true };
} catch (err) { } catch (err) {
logger.error({ err }, "Failed to launch install settings script"); logger.error({ err }, "Failed to launch update check script");
return { success: false, message: (err as Error)?.message || String(err) }; return { success: false, message: (err as Error)?.message || String(err) };
} }
}); });
+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;
+4 -41
View File
@@ -1,43 +1,6 @@
#!/bin/bash #!/bin/bash
readonly ACE_ENVIRONMENTS=( # 检查包是否已安装
"bookworm-run:amber-ce-bookworm" # 返回 0 表示已安装,非 0 表示未安装
"trixie-run:amber-ce-trixie"
"deepin23-run:amber-ce-deepin23"
"sid-run:amber-ce-sid"
)
dpkg -s "$1" 2>/dev/null | grep -q 'Status: install ok installed' > /dev/null 2>&1
RET="$?"
if [[ "$RET" != "0" ]] && [[ "$IS_ACE_ENV" == "" ]];then ## 如果未在ACE环境中
for ace_entry in "${ACE_ENVIRONMENTS[@]}"; do dpkg -s "$1" 2>/dev/null | grep -q 'Status: install ok installed'
ace_cmd=${ace_entry%%:*} exit $?
if command -v "$ace_cmd" >/dev/null 2>&1; then
echo "----------------------------------------"
echo "正在检查 $ace_cmd 环境的安装..."
echo "----------------------------------------"
# 在ACE环境中使用dpkg -s检查安装状态
# 使用dpkg -s并检查输出中是否包含"Status: install ok installed"
$ace_cmd dpkg -s "$1" 2>/dev/null | grep -q 'Status: install ok installed'
try_run_ret="$?"
# 最终检测结果处理
if [ "$try_run_ret" -eq 0 ]; then
echo "----------------------------------------"
echo "在 $ace_cmd 环境中找到了安装"
echo "----------------------------------------"
exit $try_run_ret
else
echo "----------------------------------------"
echo "在 $ace_cmd 环境中未能找到安装,继续查找"
echo "----------------------------------------"
fi
fi
done
echo "----------------------------------------"
echo "所有已安装的 ACE 环境中未能找到安装,退出"
echo "----------------------------------------"
exit "$RET"
fi
## 如果在ACE环境中或者未出错
exit "$RET"
+17 -15
View File
@@ -1,12 +1,12 @@
{ {
"name": "spark-store", "name": "spark-store",
"version": "5.0.0beta4", "version": "5.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "spark-store", "name": "spark-store",
"version": "5.0.0beta4", "version": "5.0.0",
"license": "GPL-3.0", "license": "GPL-3.0",
"dependencies": { "dependencies": {
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
@@ -28,7 +28,7 @@
"@vue/test-utils": "^2.4.3", "@vue/test-utils": "^2.4.3",
"conventional-changelog": "^7.1.1", "conventional-changelog": "^7.1.1",
"conventional-changelog-angular": "^8.1.0", "conventional-changelog-angular": "^8.1.0",
"electron": "^40.0.0", "electron": "^39.2.7",
"eslint": "^9.39.2", "eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.5", "eslint-plugin-prettier": "^5.5.5",
@@ -4870,15 +4870,15 @@
} }
}, },
"node_modules/electron": { "node_modules/electron": {
"version": "40.8.5", "version": "39.2.7",
"resolved": "https://registry.npmjs.org/electron/-/electron-40.8.5.tgz", "resolved": "https://registry.npmjs.org/electron/-/electron-39.2.7.tgz",
"integrity": "sha512-pgTY/VPQKaiU4sTjfU96iyxCXrFm4htVPCMRT4b7q9ijNTRgtLmLvcmzp2G4e7xDrq9p7OLHSmu1rBKFf6Y1/A==", "integrity": "sha512-KU0uFS6LSTh4aOIC3miolcbizOFP7N1M46VTYVfqIgFiuA2ilfNaOHLDS9tCMvwwHRowAsvqBrh9NgMXcTOHCQ==",
"dev": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@electron/get": "^2.0.0", "@electron/get": "^2.0.0",
"@types/node": "^24.9.0", "@types/node": "^22.7.7",
"extract-zip": "^2.0.1" "extract-zip": "^2.0.1"
}, },
"bin": { "bin": {
@@ -4889,19 +4889,21 @@
} }
}, },
"node_modules/electron/node_modules/@types/node": { "node_modules/electron/node_modules/@types/node": {
"version": "24.12.0", "version": "22.19.17",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz",
"integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~7.16.0" "undici-types": "~6.21.0"
} }
}, },
"node_modules/electron/node_modules/undici-types": { "node_modules/electron/node_modules/undici-types": {
"version": "7.16.0", "version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true "dev": true,
"license": "MIT"
}, },
"node_modules/emoji-regex": { "node_modules/emoji-regex": {
"version": "8.0.0", "version": "8.0.0",
+10464
View File
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "spark-store", "name": "spark-store",
"version": "5.0.0beta4", "version": "5.1.1",
"main": "dist-electron/main/index.js", "main": "dist-electron/main/index.js",
"description": "Client for Spark App Store", "description": "Client for Spark App Store",
"author": "elysia-best <elysia-best@simplelinux.cn.eu.org>", "author": "elysia-best <elysia-best@simplelinux.cn.eu.org>",
@@ -30,6 +30,7 @@
"build:vite": "vue-tsc --noEmit && vite build --mode production", "build:vite": "vue-tsc --noEmit && vite build --mode production",
"build:rpm": "vue-tsc --noEmit && vite build --mode production && electron-builder --config electron-builder.yml --linux rpm", "build:rpm": "vue-tsc --noEmit && vite build --mode production && electron-builder --config electron-builder.yml --linux rpm",
"build:deb": "vue-tsc --noEmit && vite build --mode production && electron-builder --config electron-builder.yml --linux deb", "build:deb": "vue-tsc --noEmit && vite build --mode production && electron-builder --config electron-builder.yml --linux deb",
"build:deb-loong64": "vue-tsc --noEmit && vite build --mode production && env ELECTRON_MIRROR=https://github.com/darkyzhou/electron-loong64/releases/download/ electron_use_remote_checksums=1 electron-builder --config electron-builder.yml --loong64 --linux deb",
"preview": "vite preview --mode debug", "preview": "vite preview --mode debug",
"lint": "eslint --ext .ts,.vue src electron", "lint": "eslint --ext .ts,.vue src electron",
"lint:fix": "eslint --ext .ts,.vue src electron --fix", "lint:fix": "eslint --ext .ts,.vue src electron --fix",
@@ -56,7 +57,7 @@
"@vue/test-utils": "^2.4.3", "@vue/test-utils": "^2.4.3",
"conventional-changelog": "^7.1.1", "conventional-changelog": "^7.1.1",
"conventional-changelog-angular": "^8.1.0", "conventional-changelog-angular": "^8.1.0",
"electron": "^40.0.0", "electron": "^39.2.7",
"eslint": "^9.39.2", "eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.5", "eslint-plugin-prettier": "^5.5.5",
+4 -3
View File
@@ -324,7 +324,8 @@ bool AppDelegate::editorEvent(QEvent *event, QAbstractItemModel *model,
QRect unignoreButtonRect(option.rect.right() - 80, option.rect.top() + (option.rect.height() - 30) / 2, 70, 30); QRect unignoreButtonRect(option.rect.right() - 80, option.rect.top() + (option.rect.height() - 30) / 2, 70, 30);
if (unignoreButtonRect.contains(mouseEvent->pos())) { if (unignoreButtonRect.contains(mouseEvent->pos())) {
// 发送取消忽略信号 // 发送取消忽略信号
emit unignoreApp(packageName); QString newVersion = index.data(Qt::UserRole + 3).toString();
emit unignoreApp(packageName, newVersion);
return true; return true;
} }
return true; // 消耗其他事件,不允许其他交互 return true; // 消耗其他事件,不允许其他交互
@@ -369,8 +370,8 @@ bool AppDelegate::editorEvent(QEvent *event, QAbstractItemModel *model,
// 检查是否点击了忽略按钮 // 检查是否点击了忽略按钮
QRect ignoreButtonRect(rect.right() - 160, rect.top() + (rect.height() - 30) / 2, 70, 30); QRect ignoreButtonRect(rect.right() - 160, rect.top() + (rect.height() - 30) / 2, 70, 30);
if (ignoreButtonRect.contains(mouseEvent->pos())) { if (ignoreButtonRect.contains(mouseEvent->pos())) {
QString currentVersion = index.data(Qt::UserRole + 2).toString(); QString newVersion = index.data(Qt::UserRole + 3).toString();
emit ignoreApp(packageName, currentVersion); emit ignoreApp(packageName, newVersion);
return true; return true;
} }
+1 -1
View File
@@ -45,7 +45,7 @@ signals:
void updateDisplay(const QString &packageName); void updateDisplay(const QString &packageName);
void updateFinished(bool success); //传递是否完成更新 void updateFinished(bool success); //传递是否完成更新
void ignoreApp(const QString &packageName, const QString &version); // 新增:忽略应用信号 void ignoreApp(const QString &packageName, const QString &version); // 新增:忽略应用信号
void unignoreApp(const QString &packageName); // 新增:取消忽略应用信号 void unignoreApp(const QString &packageName, const QString &version); // 新增:取消忽略应用信号
private slots: private slots:
void updateSpinner(); // 新增槽函数 void updateSpinner(); // 新增槽函数
@@ -37,6 +37,7 @@ void DownloadManager::startDownload(const QString &packageName, const QString &u
QStringList arguments = { QStringList arguments = {
"--enable-rpc=false", "--enable-rpc=false",
"--console-log-level=warn", "--console-log-level=warn",
"--async-dns=false",
"--summary-interval=1", "--summary-interval=1",
"--allow-overwrite=true", "--allow-overwrite=true",
"--connect-timeout=30", "--connect-timeout=30",
+8 -37
View File
@@ -4,40 +4,19 @@
#include <QFile> #include <QFile>
#include <QTextStream> #include <QTextStream>
#include <QDebug> #include <QDebug>
#include <unistd.h> // for geteuid
IgnoreConfig::IgnoreConfig(QObject *parent) IgnoreConfig::IgnoreConfig(QObject *parent)
: QObject(parent) : QObject(parent)
{ {
// 设置配置文件路径
QString configDir; QString configDir;
QByteArray sudoUserHomeEnv = qgetenv("SUDO_USER_HOME");
// // 检查是否以 root 权限运行 if (!sudoUserHomeEnv.isEmpty()) {
// if (geteuid() == 0) { configDir = QString::fromLocal8Bit(sudoUserHomeEnv) + "/.config";
// // 首先检查是否有 SUDO_USER_HOME 环境变量(表示是通过 pkexec 提权的普通用户) } else {
// QByteArray sudoUserHomeEnv = qgetenv("SUDO_USER_HOME"); configDir = QStandardPaths::writableLocation(QStandardPaths::ConfigLocation);
// if (!sudoUserHomeEnv.isEmpty()) { }
// // 通过 pkexec 提权的普通用户,使用原用户的配置目录
// QString sudoUserHomePath = QString::fromLocal8Bit(sudoUserHomeEnv);
// configDir = sudoUserHomePath + "/.config";
// } else {
// // 获取实际的 HOME 目录来判断是真正的 root 用户还是其他方式提权的用户
// QByteArray homeEnv = qgetenv("HOME");
// QString homePath = QString::fromLocal8Bit(homeEnv);
// if (homePath == "/root") {
// // 真正的 root 用户,使用 /root/.config
// configDir = "/root/.config";
// } else {
// // 其他方式提权的用户,使用 HOME 目录下的配置
// configDir = homePath + "/.config";
// }
// }
// } else {
// // 普通用户,使用标准配置目录
// configDir = QStandardPaths::writableLocation(QStandardPaths::ConfigLocation);
// }
configDir = "/etc/";
QDir dir(configDir); QDir dir(configDir);
if (!dir.exists()) { if (!dir.exists()) {
dir.mkpath("."); dir.mkpath(".");
@@ -64,17 +43,9 @@ void IgnoreConfig::addIgnoredApp(const QString &packageName, const QString &vers
saveConfig(); saveConfig();
} }
void IgnoreConfig::removeIgnoredApp(const QString &packageName) void IgnoreConfig::removeIgnoredApp(const QString &packageName, const QString &version)
{ {
// 移除所有该包名的版本 m_ignoredApps.remove(qMakePair(packageName, version));
auto it = m_ignoredApps.begin();
while (it != m_ignoredApps.end()) {
if (it->first == packageName) {
it = m_ignoredApps.erase(it);
} else {
++it;
}
}
saveConfig(); saveConfig();
} }
+1 -1
View File
@@ -17,7 +17,7 @@ public:
void addIgnoredApp(const QString &packageName, const QString &version); void addIgnoredApp(const QString &packageName, const QString &version);
// 移除忽略的应用 // 移除忽略的应用
void removeIgnoredApp(const QString &packageName); void removeIgnoredApp(const QString &packageName, const QString &version);
// 检查应用是否被忽略 // 检查应用是否被忽略
bool isAppIgnored(const QString &packageName, const QString &version) const; bool isAppIgnored(const QString &packageName, const QString &version) const;
+4 -4
View File
@@ -238,10 +238,10 @@ void MainWindow::checkUpdates()
for (const auto &item : updateInfo) { for (const auto &item : updateInfo) {
QJsonObject obj = item.toObject(); QJsonObject obj = item.toObject();
QString packageName = obj["package"].toString(); QString packageName = obj["package"].toString();
QString currentVersion = obj["current_version"].toString(); QString newVersion = obj["new_version"].toString();
// 检查应用是否被忽略 // 检查应用是否被忽略
if (m_ignoreConfig->isAppIgnored(packageName, currentVersion)) { if (m_ignoreConfig->isAppIgnored(packageName, newVersion)) {
// 标记为忽略状态 // 标记为忽略状态
obj["ignored"] = true; obj["ignored"] = true;
ignoredApps.append(obj); ignoredApps.append(obj);
@@ -468,9 +468,9 @@ void MainWindow::onIgnoreApp(const QString &packageName, const QString &version)
} }
// 新增:处理取消忽略应用的槽函数 // 新增:处理取消忽略应用的槽函数
void MainWindow::onUnignoreApp(const QString &packageName) { void MainWindow::onUnignoreApp(const QString &packageName, const QString &version) {
// 从忽略配置中移除应用 // 从忽略配置中移除应用
m_ignoreConfig->removeIgnoredApp(packageName); m_ignoreConfig->removeIgnoredApp(packageName, version);
// 更新模型中应用的状态 // 更新模型中应用的状态
QJsonArray updatedApps; QJsonArray updatedApps;
+1 -1
View File
@@ -43,6 +43,6 @@ private slots:
void handleUpdateFinished(bool success); // 新增:处理更新完成的槽函数 void handleUpdateFinished(bool success); // 新增:处理更新完成的槽函数
void handleSelectionChanged(); // 新增:处理选择变化的槽函数 void handleSelectionChanged(); // 新增:处理选择变化的槽函数
void onIgnoreApp(const QString &packageName, const QString &version); // 新增:处理忽略应用的槽函数 void onIgnoreApp(const QString &packageName, const QString &version); // 新增:处理忽略应用的槽函数
void onUnignoreApp(const QString &packageName); // 新增:处理取消忽略应用 void onUnignoreApp(const QString &packageName, const QString &version); // 新增:处理取消忽略应用
}; };
#endif // MAINWINDOW_H #endif // MAINWINDOW_H
+129 -26
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,9 +121,12 @@
: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"
@open-app="openDownloadedApp($event.pkgname, $event.origin)"
@open-detail="openDetail"
@uninstall="uninstallInstalledApp" @uninstall="uninstallInstalledApp"
@switch-origin="handleSwitchOrigin" @switch-origin="handleSwitchOrigin"
/> />
@@ -148,7 +152,15 @@
@success="onUninstallSuccess" @success="onUninstallSuccess"
/> />
<ApmInstallConfirmModal
:show="showApmInstallDialog"
@close="closeApmInstallDialog"
@confirm="confirmApmInstall"
/>
<AboutModal :show="showAboutModal" @close="closeAboutModal" /> <AboutModal :show="showAboutModal" @close="closeAboutModal" />
<SettingsModal :show="showSettingsModal" @close="closeSettingsModal" />
</div> </div>
</template> </template>
@@ -167,13 +179,16 @@ import DownloadDetail from "./components/DownloadDetail.vue";
import InstalledAppsModal from "./components/InstalledAppsModal.vue"; import InstalledAppsModal from "./components/InstalledAppsModal.vue";
import UpdateCenterModal from "./components/UpdateCenterModal.vue"; import UpdateCenterModal from "./components/UpdateCenterModal.vue";
import UninstallConfirmModal from "./components/UninstallConfirmModal.vue"; import UninstallConfirmModal from "./components/UninstallConfirmModal.vue";
import ApmInstallConfirmModal from "./components/ApmInstallConfirmModal.vue";
import AboutModal from "./components/AboutModal.vue"; import AboutModal from "./components/AboutModal.vue";
import SettingsModal from "./components/SettingsModal.vue";
import { import {
APM_STORE_BASE_URL, APM_STORE_BASE_URL,
currentApp, currentApp,
currentAppSparkInstalled, currentAppSparkInstalled,
currentAppApmInstalled, currentAppApmInstalled,
currentStoreMode, currentStoreMode,
showApmInstallDialog,
getHybridDefaultOrigin, getHybridDefaultOrigin,
loadPriorityConfig, loadPriorityConfig,
} from "./global/storeConfig"; } from "./global/storeConfig";
@@ -187,6 +202,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,
@@ -225,8 +246,6 @@ const fetchWithRetry = async <T,>(
} }
}; };
const cacheBuster = (url: string) => `${url}?cb=${Date.now()}`;
// 响应式状态 // 响应式状态
const themeMode = ref<"light" | "dark" | "auto">("auto"); const themeMode = ref<"light" | "dark" | "auto">("auto");
const systemIsDark = ref( const systemIsDark = ref(
@@ -258,10 +277,19 @@ const updateCenterStore = createUpdateCenterStore();
const showUninstallModal = ref(false); 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 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(() => {
@@ -379,7 +407,7 @@ const fetchAppFromStore = async (
const arch = window.apm_store.arch || "amd64"; const arch = window.apm_store.arch || "amd64";
const finalArch = origin === "spark" ? `${arch}-store` : `${arch}-apm`; const finalArch = origin === "spark" ? `${arch}-store` : `${arch}-apm`;
const appJsonUrl = `${APM_STORE_BASE_URL}/${finalArch}/${category}/${pkgname}/app.json`; const appJsonUrl = `${APM_STORE_BASE_URL}/${finalArch}/${category}/${pkgname}/app.json`;
const response = await fetch(cacheBuster(appJsonUrl)); const response = await fetch(appJsonUrl);
if (!response.ok) return null; if (!response.ok) return null;
const appJson = await response.json(); const appJson = await response.json();
return { return {
@@ -650,7 +678,7 @@ const loadHome = async () => {
// homelinks.json // homelinks.json
try { try {
const res = await fetch(cacheBuster(`${base}/homelinks.json`)); const res = await fetch(`${base}/homelinks.json`);
if (res.ok) { if (res.ok) {
const links = await res.json(); const links = await res.json();
const taggedLinks = links.map((l: HomeLink) => ({ const taggedLinks = links.map((l: HomeLink) => ({
@@ -665,14 +693,14 @@ const loadHome = async () => {
// homelist.json // homelist.json
try { try {
const res2 = await fetch(cacheBuster(`${base}/homelist.json`)); const res2 = await fetch(`${base}/homelist.json`);
if (res2.ok) { if (res2.ok) {
const lists = await res2.json(); const lists = await res2.json();
for (const item of lists) { for (const item of lists) {
if (item.type === "appList" && item.jsonUrl) { if (item.type === "appList" && item.jsonUrl) {
try { try {
const url = `${APM_STORE_BASE_URL}/${finalArch}${item.jsonUrl}`; const url = `${APM_STORE_BASE_URL}/${finalArch}${item.jsonUrl}`;
const r = await fetch(cacheBuster(url)); const r = await fetch(url);
if (r.ok) { if (r.ok) {
const appsJson = await r.json(); const appsJson = await r.json();
const rawApps = appsJson || []; const rawApps = appsJson || [];
@@ -690,7 +718,7 @@ const loadHome = async () => {
try { try {
const realAppUrl = `${APM_STORE_BASE_URL}/${finalArch}/${baseApp.category}/${baseApp.pkgname}/app.json`; const realAppUrl = `${APM_STORE_BASE_URL}/${finalArch}/${baseApp.category}/${baseApp.pkgname}/app.json`;
const realRes = await fetch(cacheBuster(realAppUrl)); const realRes = await fetch(realAppUrl);
if (realRes.ok) { if (realRes.ok) {
const realApp = await realRes.json(); const realApp = await realRes.json();
if (realApp.Filename) if (realApp.Filename)
@@ -745,15 +773,8 @@ const handleUpdate = async () => {
await openUpdateModal(); await openUpdateModal();
}; };
const handleOpenInstallSettings = async () => { const handleOpenInstallSettings = () => {
try { showSettingsModal.value = true;
const result = await window.ipcRenderer.invoke("open-install-settings");
if (!result || !result.success) {
logger.warn(`启动安装设置失败: ${result?.message || "未知错误"}`);
}
} catch (error) {
logger.error(`调用安装设置时出错: ${error}`);
}
}; };
const handleList = () => { const handleList = () => {
@@ -762,7 +783,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}`);
} }
@@ -792,11 +817,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();
}; };
@@ -805,7 +840,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();
}; };
@@ -813,8 +853,37 @@ const refreshInstalledApps = async () => {
installedLoading.value = true; installedLoading.value = true;
installedError.value = ""; installedError.value = "";
try { try {
const origin = activeInstalledOrigin.value; const origin = getAllowedInstalledOrigin(
const result = await window.ipcRenderer.invoke("list-installed", origin); 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 优化:只检查远端商店目录中的应用,避免全量扫描
let pkgnameList: string[] | undefined;
if (origin === "spark") {
pkgnameList = apps.value
.filter((a) => a.origin === "spark")
.map((a) => a.pkgname);
}
const result = await window.ipcRenderer.invoke("list-installed", {
origin,
pkgnameList,
});
if (!result?.success) { if (!result?.success) {
installedApps.value = []; installedApps.value = [];
installedError.value = result?.message || "读取已安装应用失败"; installedError.value = result?.message || "读取已安装应用失败";
@@ -903,6 +972,22 @@ const onUninstallSuccess = () => {
} }
}; };
const closeApmInstallDialog = () => {
showApmInstallDialog.value = false;
};
const confirmApmInstall = async () => {
showApmInstallDialog.value = false;
closeDetail();
await nextTick();
const apmApp = apps.value.find((a) => a.pkgname === "apm");
if (apmApp) {
openDetail(apmApp);
} else {
searchQuery.value = "apm";
}
};
const installCompleteCallback = (pkgname?: string) => { const installCompleteCallback = (pkgname?: string) => {
if (currentApp.value && (!pkgname || currentApp.value.pkgname === pkgname)) { if (currentApp.value && (!pkgname || currentApp.value.pkgname === pkgname)) {
checkAppInstalled(currentApp.value); checkAppInstalled(currentApp.value);
@@ -923,6 +1008,10 @@ const closeAboutModal = () => {
showAboutModal.value = false; showAboutModal.value = false;
}; };
const closeSettingsModal = () => {
showSettingsModal.value = false;
};
// TODO: 目前 APM 商店不能暂停下载 // TODO: 目前 APM 商店不能暂停下载
const pauseDownload = (id: DownloadItem) => { const pauseDownload = (id: DownloadItem) => {
const download = downloads.value.find((d) => d.id === id.id); const download = downloads.value.find((d) => d.id === id.id);
@@ -1014,7 +1103,7 @@ const loadCategories = async () => {
const path = `/${finalArch}/categories.json`; const path = `/${finalArch}/categories.json`;
try { try {
const response = await axiosInstance.get(cacheBuster(path)); const response = await axiosInstance.get(path);
const data = response.data; const data = response.data;
Object.keys(data).forEach((key) => { Object.keys(data).forEach((key) => {
if (categoryData[key]) { if (categoryData[key]) {
@@ -1067,7 +1156,7 @@ const loadApps = async (onFirstBatch?: () => void) => {
logger.info(`加载分类: ${category} (来源: ${mode})`); logger.info(`加载分类: ${category} (来源: ${mode})`);
const categoryApps = await fetchWithRetry<AppJson[]>( const categoryApps = await fetchWithRetry<AppJson[]>(
cacheBuster(path), path,
); );
const normalizedApps = (categoryApps || []).map((appJson) => ({ const normalizedApps = (categoryApps || []).map((appJson) => ({
@@ -1136,11 +1225,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();
// 分类目录加载后,并行加载主页数据和所有应用列表 // 分类目录加载后,并行加载主页数据和所有应用列表
@@ -1201,6 +1300,10 @@ onMounted(async () => {
} }
}); });
window.ipcRenderer.on("trigger-apm-install-dialog", () => {
showApmInstallDialog.value = true;
});
window.ipcRenderer.on( window.ipcRenderer.on(
"deep-link-install", "deep-link-install",
(_event: IpcRendererEvent, pkgname: string) => { (_event: IpcRendererEvent, pkgname: string) => {
@@ -0,0 +1,78 @@
import { fireEvent, render, screen } from "@testing-library/vue";
import { beforeEach, describe, expect, it, vi } from "vitest";
import App from "@/App.vue";
const invoke = vi.fn();
const updateCenterOpen = vi.fn();
vi.mock("axios", () => {
const get = vi.fn(async () => ({ data: [] }));
return {
default: {
create: () => ({ get }),
},
};
});
describe("App update center runtime", () => {
beforeEach(() => {
invoke.mockReset();
updateCenterOpen.mockReset();
invoke.mockImplementation(async (channel: string) => {
if (channel === "get-store-filter") return "both";
if (channel === "check-spark-available") return true;
if (channel === "check-apm-available") return true;
if (channel === "get-app-version") return "5.0.0";
return [];
});
Object.assign(window.ipcRenderer, {
invoke,
on: vi.fn(),
off: vi.fn(),
send: vi.fn(),
});
Object.assign(window, {
updateCenter: {
open: updateCenterOpen.mockResolvedValue({
items: [],
tasks: [],
warnings: [],
hasRunningTasks: false,
}),
refresh: vi.fn(),
ignore: vi.fn(),
unignore: vi.fn(),
start: vi.fn(),
cancel: vi.fn(),
getState: vi.fn(),
onState: vi.fn(),
offState: vi.fn(),
},
});
window.apm_store.arch = "amd64";
vi.stubGlobal(
"matchMedia",
vi.fn(() => ({
matches: false,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
})),
);
});
it("opens update center with an empty snapshot without throwing", async () => {
render(App);
await fireEvent.click(await screen.findByText("软件更新"));
expect(updateCenterOpen).toHaveBeenCalledWith("both");
expect(await screen.findByText("暂无可展示的更新任务")).toBeTruthy();
});
});
@@ -0,0 +1,100 @@
import { fireEvent, render, screen } from "@testing-library/vue";
import { beforeEach, describe, expect, it, vi } from "vitest";
import App from "@/App.vue";
const invoke = vi.fn();
const open = vi.fn();
vi.mock("axios", () => {
const get = vi.fn(async (url: string) => {
if (url.includes("categories.json")) {
return { data: {} };
}
if (url.includes("homelinks.json") || url.includes("homelist.json")) {
return { data: [] };
}
if (url.includes("applist.json")) {
return { data: [] };
}
return { data: [] };
});
return {
default: {
create: () => ({ get }),
},
};
});
vi.mock("@/modules/updateCenter", () => ({
createUpdateCenterStore: () => ({
isOpen: { value: false },
showCloseConfirm: { value: false },
showMigrationConfirm: { value: false },
searchQuery: { value: "" },
selectedTaskKeys: { value: new Set<string>() },
snapshot: {
value: { items: [], tasks: [], warnings: [], hasRunningTasks: false },
},
filteredItems: { value: [] },
allSelected: { value: false },
someSelected: { value: false },
bind: vi.fn(),
unbind: vi.fn(),
open,
refresh: vi.fn(),
ignoreItem: vi.fn(),
unignoreItem: vi.fn(),
toggleSelection: vi.fn(),
toggleSelectAll: vi.fn(),
getSelectedItems: vi.fn(() => []),
closeNow: vi.fn(),
startSelected: vi.fn(),
requestClose: vi.fn(),
}),
}));
describe("App update center entry", () => {
beforeEach(() => {
invoke.mockReset();
open.mockReset();
invoke.mockImplementation(async (channel: string) => {
if (channel === "get-store-filter") return "both";
if (channel === "check-spark-available") return true;
if (channel === "check-apm-available") return true;
if (channel === "get-app-version") return "5.0.0";
return [];
});
Object.assign(window.ipcRenderer, {
invoke,
on: vi.fn(),
off: vi.fn(),
send: vi.fn(),
});
window.apm_store.arch = "amd64";
vi.stubGlobal(
"matchMedia",
vi.fn(() => ({
matches: false,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
})),
);
});
it("opens update center when clicking the sidebar action", async () => {
render(App);
await fireEvent.click(await screen.findByText("软件更新"));
expect(open).toHaveBeenCalledWith("both");
});
});
+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();
});
});
+120 -1
View File
@@ -1,7 +1,29 @@
import { render, screen } from "@testing-library/vue"; import { fireEvent, render, screen } from "@testing-library/vue";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import InstalledAppsModal from "@/components/InstalledAppsModal.vue"; import InstalledAppsModal from "@/components/InstalledAppsModal.vue";
import type { App } from "@/global/typedefinition";
const createApp = (overrides: Partial<App> = {}): App => ({
name: "Spark Notes",
pkgname: "spark-notes",
version: "1.0.0",
filename: "spark-notes.deb",
torrent_address: "",
author: "",
contributor: "",
website: "",
update: "",
size: "1 MB",
more: "",
tags: "",
img_urls: [],
icons: "",
category: "office",
origin: "spark",
currentStatus: "installed",
...overrides,
});
describe("InstalledAppsModal", () => { describe("InstalledAppsModal", () => {
it("keeps scroll chaining inside the modal list", () => { it("keeps scroll chaining inside the modal list", () => {
@@ -13,6 +35,7 @@ describe("InstalledAppsModal", () => {
error: "", error: "",
activeOrigin: "spark", activeOrigin: "spark",
storeFilter: "both", storeFilter: "both",
sparkAvailable: true,
apmAvailable: true, apmAvailable: true,
}, },
}); });
@@ -22,4 +45,100 @@ describe("InstalledAppsModal", () => {
expect(scrollContainer?.className).toContain("overscroll-contain"); expect(scrollContainer?.className).toContain("overscroll-contain");
}); });
it("renders open and detail actions for a store-backed installed app", () => {
render(InstalledAppsModal, {
props: {
show: true,
apps: [createApp()],
loading: false,
error: "",
activeOrigin: "spark",
storeFilter: "both",
sparkAvailable: true,
apmAvailable: true,
},
});
expect(screen.getByRole("button", { name: "打开" })).toBeTruthy();
expect(screen.getByRole("button", { name: "查看详情" })).toBeTruthy();
});
it("emits open-app when clicking 打开", async () => {
const rendered = render(InstalledAppsModal, {
props: {
show: true,
apps: [createApp()],
loading: false,
error: "",
activeOrigin: "spark",
storeFilter: "both",
sparkAvailable: true,
apmAvailable: true,
},
});
await fireEvent.click(screen.getByRole("button", { name: "打开" }));
expect(rendered.emitted("open-app")).toHaveLength(1);
expect(rendered.emitted("open-app")?.[0]?.[0]).toMatchObject({
pkgname: "spark-notes",
});
});
it("emits open-detail when clicking 查看详情", async () => {
const rendered = render(InstalledAppsModal, {
props: {
show: true,
apps: [createApp()],
loading: false,
error: "",
activeOrigin: "spark",
storeFilter: "both",
sparkAvailable: true,
apmAvailable: true,
},
});
await fireEvent.click(screen.getByRole("button", { name: "查看详情" }));
expect(rendered.emitted("open-detail")).toHaveLength(1);
expect(rendered.emitted("open-detail")?.[0]?.[0]).toMatchObject({
pkgname: "spark-notes",
});
});
it("shows 查看详情 for metadata-rich unknown-category apps", () => {
render(InstalledAppsModal, {
props: {
show: true,
apps: [createApp({ category: "unknown", more: "Has store metadata" })],
loading: false,
error: "",
activeOrigin: "spark",
storeFilter: "both",
sparkAvailable: true,
apmAvailable: true,
},
});
expect(screen.getByRole("button", { name: "查看详情" })).toBeTruthy();
});
it("hides 查看详情 for unknown-category apps", () => {
render(InstalledAppsModal, {
props: {
show: true,
apps: [createApp({ category: "unknown" })],
loading: false,
error: "",
activeOrigin: "spark",
storeFilter: "both",
sparkAvailable: true,
apmAvailable: true,
},
});
expect(screen.queryByRole("button", { name: "查看详情" })).toBeNull();
});
}); });
+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,
);
});
});
@@ -63,16 +63,21 @@ const createStore = (
return { return {
isOpen: ref(true), isOpen: ref(true),
loading: ref(false),
showCloseConfirm: ref(true), showCloseConfirm: ref(true),
showMigrationConfirm: ref(false), showMigrationConfirm: ref(false),
searchQuery: ref(""), searchQuery: ref(""),
selectedTaskKeys, selectedTaskKeys,
snapshot, snapshot,
filteredItems: computed(() => snapshot.value.items), filteredItems: computed(() => snapshot.value.items),
allSelected: computed(() => false),
someSelected: computed(() => selectedTaskKeys.value.size > 0),
bind: vi.fn(), bind: vi.fn(),
unbind: vi.fn(), unbind: vi.fn(),
open: vi.fn(), open: vi.fn(),
refresh: vi.fn(), refresh: vi.fn(),
ignoreItem: vi.fn(),
unignoreItem: vi.fn(),
toggleSelection: vi.fn(), toggleSelection: vi.fn(),
getSelectedItems: vi.fn(() => getSelectedItems: vi.fn(() =>
snapshot.value.items.filter( snapshot.value.items.filter(
@@ -87,7 +92,35 @@ const createStore = (
}; };
describe("UpdateCenterModal", () => { describe("UpdateCenterModal", () => {
it("renders source tags, running state, warnings, migration marker, and close confirmation", () => { it("constrains the update list so it can scroll with a visible scrollbar", () => {
const store = createStore({
items: Array.from({ length: 20 }, (_, index) =>
createItem({
taskKey: `aptss:spark-item-${index}`,
packageName: `spark-item-${index}`,
displayName: `Spark Item ${index}`,
}),
),
tasks: [],
warnings: [],
hasRunningTasks: false,
});
const { container } = render(UpdateCenterModal, {
props: {
show: true,
store,
},
});
const scrollContainer = container.querySelector(".scrollbar-muted");
expect(scrollContainer?.className).toContain("overflow-y-auto");
expect(scrollContainer?.className).toContain("flex-1");
expect(scrollContainer?.className).toContain("overscroll-contain");
});
it("renders source tags, running state, warnings, and migration marker", () => {
const store = createStore(); const store = createStore();
render(UpdateCenterModal, { render(UpdateCenterModal, {
@@ -104,24 +137,6 @@ describe("UpdateCenterModal", () => {
expect(screen.getByText("更新过程中请勿关闭商店")).toBeTruthy(); expect(screen.getByText("更新过程中请勿关闭商店")).toBeTruthy();
expect(screen.getByText("下载中")).toBeTruthy(); expect(screen.getByText("下载中")).toBeTruthy();
expect(screen.getByText("42%")).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", () => { it("renders ignored items as disabled instead of normal selectable actions", () => {
@@ -148,7 +163,34 @@ describe("UpdateCenterModal", () => {
}); });
expect(screen.getByText("已忽略")).toBeTruthy(); expect(screen.getByText("已忽略")).toBeTruthy();
expect(screen.getByRole("checkbox")).toBeDisabled(); expect(screen.getAllByRole("checkbox").at(-1)).toBeDisabled();
expect(screen.getByRole("button", { name: "取消忽略" })).toBeTruthy();
});
it("renders ignore action for normal items", () => {
const store = createStore({
items: [
createItem({
taskKey: "aptss:spark-weather",
packageName: "spark-weather",
displayName: "Spark Weather",
source: "aptss",
ignored: false,
}),
],
tasks: [],
warnings: [],
hasRunningTasks: false,
});
render(UpdateCenterModal, {
props: {
show: true,
store,
},
});
expect(screen.getByRole("button", { name: "忽略更新" })).toBeTruthy();
}); });
it("renders migration confirmation when requested", () => { it("renders migration confirmation when requested", () => {
@@ -179,4 +221,52 @@ describe("UpdateCenterModal", () => {
expect(store.requestClose).toHaveBeenCalledTimes(1); expect(store.requestClose).toHaveBeenCalledTimes(1);
}); });
it("shows loading panel when loading with no items", () => {
const store = createStore({
items: [],
tasks: [],
warnings: [],
hasRunningTasks: false,
});
store.loading.value = true;
render(UpdateCenterModal, {
props: {
show: true,
store,
},
});
expect(screen.getByText("正在检查更新…")).toBeTruthy();
});
it("shows refresh hint while loading with existing items", () => {
const store = createStore({ hasRunningTasks: false });
store.loading.value = true;
render(UpdateCenterModal, {
props: {
show: true,
store,
},
});
expect(screen.getByText("Spark Weather")).toBeTruthy();
expect(screen.getByText("正在刷新更新列表…")).toBeTruthy();
});
it("disables refresh button while loading", () => {
const store = createStore({ hasRunningTasks: false });
store.loading.value = true;
render(UpdateCenterModal, {
props: {
show: true,
store,
},
});
expect(screen.getByRole("button", { name: /刷新/ })).toBeDisabled();
});
}); });
@@ -1,4 +1,5 @@
import { mkdtemp, readFile, rm } from "node:fs/promises"; import { mkdtemp, readFile, rm } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
@@ -6,7 +7,7 @@ import { describe, expect, it } from "vitest";
import type { UpdateCenterItem } from "../../../../electron/main/backend/update-center/types"; import type { UpdateCenterItem } from "../../../../electron/main/backend/update-center/types";
import { import {
LEGACY_IGNORE_CONFIG_PATH, IGNORE_CONFIG_PATH,
applyIgnoredEntries, applyIgnoredEntries,
createIgnoreKey, createIgnoreKey,
loadIgnoredEntries, loadIgnoredEntries,
@@ -15,9 +16,9 @@ import {
} from "../../../../electron/main/backend/update-center/ignore-config"; } from "../../../../electron/main/backend/update-center/ignore-config";
describe("update-center ignore config", () => { describe("update-center ignore config", () => {
it("round-trips the legacy package|version format", async () => { it("round-trips the package|version format at the user config path", async () => {
expect(LEGACY_IGNORE_CONFIG_PATH).toBe( expect(IGNORE_CONFIG_PATH).toBe(
"/etc/spark-store/ignored_apps.conf", join(homedir(), ".config", "spark-store", "ignored_apps.conf"),
); );
const entries = new Set([ const entries = new Set([
@@ -11,7 +11,7 @@ type RemoteStoreResponse =
| Array<Record<string, unknown>>; | Array<Record<string, unknown>>;
const APTSS_LIST_UPGRADABLE_KEY = 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"; "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 | awk 'NR>1'";
const DPKG_QUERY_INSTALLED_KEY = const DPKG_QUERY_INSTALLED_KEY =
"dpkg-query -W -f=${Package}\t${db:Status-Want} ${db:Status-Status} ${db:Status-Eflag}\n"; "dpkg-query -W -f=${Package}\t${db:Status-Want} ${db:Status-Status} ${db:Status-Eflag}\n";
@@ -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,
{ {
@@ -172,15 +191,18 @@ describe("update-center load items", () => {
], ],
}); });
const result = await loadUpdateCenterItems(async (command, args) => { const result = await loadUpdateCenterItems(
const key = `${command} ${args.join(" ")}`; "both",
const match = commandResults.get(key); async (command, args) => {
if (!match) { const key = `${command} ${args.join(" ")}`;
throw new Error(`Missing mock for ${key}`); const match = commandResults.get(key);
} if (!match) {
throw new Error(`Missing mock for ${key}`);
}
return match; return match;
}); },
);
expect(result.warnings).toEqual([]); expect(result.warnings).toEqual([]);
expect(result.items).toContainEqual({ expect(result.items).toContainEqual({
@@ -214,44 +236,64 @@ describe("update-center load items", () => {
], ],
}); });
const result = await loadUpdateCenterItems(async (command, args) => { const result = await loadUpdateCenterItems(
const key = `${command} ${args.join(" ")}`; "both",
async (command, args) => {
const key = `${command} ${args.join(" ")}`;
if (key === APTSS_LIST_UPGRADABLE_KEY) { if (key === WHICH_APTSS_KEY) {
return { return { code: 0, stdout: "/usr/bin/aptss\n", stderr: "" };
code: 0, }
stdout: "spark-notes/stable 2.0.0 amd64 [upgradable from: 1.0.0]",
stderr: "",
};
}
if (key === DPKG_QUERY_INSTALLED_KEY) { if (key === WHICH_APM_KEY) {
return { return { code: 127, stdout: "", stderr: "apm: command not found" };
code: 0, }
stdout: "spark-notes\tinstall ok installed\n",
stderr: "",
};
}
if (key === APTSS_NOTES_PRINT_URIS_KEY) { if (key === APTSS_LIST_UPGRADABLE_KEY) {
return { return {
code: 0, code: 0,
stdout: stdout: "spark-notes/stable 2.0.0 amd64 [upgradable from: 1.0.0]",
"'https://example.invalid/spark-notes_2.0.0_amd64.deb' spark-notes_2.0.0_amd64.deb 654321 SHA512:beadfeed", stderr: "",
stderr: "", };
}; }
}
if (key === "apm list --upgradable" || key === "apm list --installed") { if (key === DPKG_QUERY_INSTALLED_KEY) {
return { return {
code: 127, code: 0,
stdout: "", stdout: "spark-notes\tinstall ok installed\n",
stderr: "apm: command not found", stderr: "",
}; };
} }
throw new Error(`Unexpected command ${key}`); if (key === APTSS_NOTES_PRINT_URIS_KEY) {
}); return {
code: 0,
stdout:
"'https://example.invalid/spark-notes_2.0.0_amd64.deb' spark-notes_2.0.0_amd64.deb 654321 SHA512:beadfeed",
stderr: "",
};
}
if (key === APTSS_NOTES_PRINT_URIS_KEY) {
return {
code: 0,
stdout:
"'https://example.invalid/spark-notes_2.0.0_amd64.deb' spark-notes_2.0.0_amd64.deb 654321 SHA512:beadfeed",
stderr: "",
};
}
if (key === "apm list --upgradable" || key === "apm list --installed") {
return {
code: 127,
stdout: "",
stderr: "apm: command not found",
};
}
throw new Error(`Unexpected command ${key}`);
},
);
expect(result.items).toEqual([ expect(result.items).toEqual([
{ {
@@ -270,10 +312,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 () => {
@@ -283,85 +322,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 === APTSS_LIST_UPGRADABLE_KEY) { if (key === WHICH_APTSS_KEY) {
return { return { code: 0, stdout: "/usr/bin/aptss\n", stderr: "" };
code: 0,
stdout: "spark-notes/stable 2.0.0 amd64 [upgradable from: 1.0.0]",
stderr: "",
};
} }
if (key === DPKG_QUERY_INSTALLED_KEY) { if (key === WHICH_APM_KEY) {
return { return { code: 127, stdout: "", stderr: "apm: command not found" };
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}`);
};
const firstResult = await loadUpdateCenterItems(runCommand);
expect(firstResult.items).toEqual([
{
pkgname: "spark-notes",
source: "aptss",
currentVersion: "1.0.0",
nextVersion: "2.0.0",
arch: "amd64",
name: "Spark Notes",
},
]);
remoteStore["https://erotica.spark-app.store/amd64-store/categories.json"] =
{
office: { zh: "Office" },
};
remoteStore[
"https://erotica.spark-app.store/amd64-store/office/applist.json"
] = [{ Name: "Spark Notes", Pkgname: "spark-notes" }];
const secondResult = await loadUpdateCenterItems(runCommand);
expect(secondResult.items).toEqual([
{
pkgname: "spark-notes",
source: "aptss",
currentVersion: "1.0.0",
nextVersion: "2.0.0",
arch: "amd64",
category: "office",
name: "Spark Notes",
remoteIcon:
"https://erotica.spark-app.store/amd64-store/office/spark-notes/icon.png",
},
]);
});
it("keeps successfully loaded categories when another category applist fetch fails", async () => {
const { loadUpdateCenterItems } = await loadUpdateCenterModule({
"https://erotica.spark-app.store/amd64-store/categories.json": {
office: { zh: "Office" },
tools: { zh: "Tools" },
},
"https://erotica.spark-app.store/amd64-store/office/applist.json": [
{ Name: "Spark Notes", Pkgname: "spark-notes" },
],
});
const result = await loadUpdateCenterItems(async (command, args) => {
const key = `${command} ${args.join(" ")}`;
if (key === APTSS_LIST_UPGRADABLE_KEY) { if (key === APTSS_LIST_UPGRADABLE_KEY) {
return { return {
code: 0, code: 0,
@@ -396,8 +364,114 @@ describe("update-center load items", () => {
} }
throw new Error(`Unexpected command ${key}`); throw new Error(`Unexpected command ${key}`);
};
const firstResult = await loadUpdateCenterItems("both", runCommand);
expect(firstResult.items).toEqual([
{
pkgname: "spark-notes",
source: "aptss",
currentVersion: "1.0.0",
nextVersion: "2.0.0",
arch: "amd64",
downloadUrl: "https://example.invalid/spark-notes_2.0.0_amd64.deb",
fileName: "spark-notes_2.0.0_amd64.deb",
size: 654321,
sha512: "beadfeed",
},
]);
remoteStore["https://erotica.spark-app.store/amd64-store/categories.json"] =
{
office: { zh: "Office" },
};
remoteStore[
"https://erotica.spark-app.store/amd64-store/office/applist.json"
] = [{ Name: "Spark Notes", Pkgname: "spark-notes" }];
const secondResult = await loadUpdateCenterItems("both", runCommand);
expect(secondResult.items).toEqual([
{
pkgname: "spark-notes",
source: "aptss",
currentVersion: "1.0.0",
nextVersion: "2.0.0",
arch: "amd64",
category: "office",
name: "Spark Notes",
remoteIcon:
"https://erotica.spark-app.store/amd64-store/office/spark-notes/icon.png",
downloadUrl: "https://example.invalid/spark-notes_2.0.0_amd64.deb",
fileName: "spark-notes_2.0.0_amd64.deb",
size: 654321,
sha512: "beadfeed",
},
]);
});
it("keeps successfully loaded categories when another category applist fetch fails", async () => {
const { loadUpdateCenterItems } = await loadUpdateCenterModule({
"https://erotica.spark-app.store/amd64-store/categories.json": {
office: { zh: "Office" },
tools: { zh: "Tools" },
},
"https://erotica.spark-app.store/amd64-store/office/applist.json": [
{ Name: "Spark Notes", Pkgname: "spark-notes" },
],
}); });
const result = await loadUpdateCenterItems(
"both",
async (command, args) => {
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) {
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: "",
};
}
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([ expect(result.items).toEqual([
{ {
pkgname: "spark-notes", pkgname: "spark-notes",
@@ -409,11 +483,128 @@ describe("update-center load items", () => {
name: "Spark Notes", name: "Spark Notes",
remoteIcon: remoteIcon:
"https://erotica.spark-app.store/amd64-store/office/spark-notes/icon.png", "https://erotica.spark-app.store/amd64-store/office/spark-notes/icon.png",
downloadUrl: "https://example.invalid/spark-notes_2.0.0_amd64.deb",
fileName: "spark-notes_2.0.0_amd64.deb",
size: 654321,
sha512: "beadfeed",
}, },
]); ]);
expect(result.warnings).toEqual([ expect(result.warnings).toEqual([]);
"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("apm", runCommand);
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("spark", runCommand);
expect(runCommand).not.toHaveBeenCalledWith("apm", [
"list",
"--upgradable",
]); ]);
expect(runCommand).not.toHaveBeenCalledWith("apm", ["list", "--installed"]);
}); });
}); });
@@ -28,6 +28,25 @@ describe("update-center query", () => {
]); ]);
}); });
it("parses aptss wrapper output with ansi noise before package lines", () => {
const output = [
"\u001b[1;32m信息:正在使用非 Root 权限模式启动!若出现问题,请尝试使用 Root 权限执行命令。\u001b[0m",
"正在列表...",
"spark-weather/stable 2.0.0 amd64 [upgradable from: 1.9.0]",
"",
].join("\n");
expect(parseAptssUpgradableOutput(output)).toEqual([
{
pkgname: "spark-weather",
source: "aptss",
currentVersion: "1.9.0",
nextVersion: "2.0.0",
arch: "amd64",
},
]);
});
it("parses the legacy from variant in upgradable output", () => { it("parses the legacy from variant in upgradable output", () => {
const aptssOutput = "spark-clock/stable 1.2.0 amd64 [from: 1.1.0]"; 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]"; const apmOutput = "spark-player/main 2.0.0 amd64 [from: 1.5.0]";
@@ -1,7 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import type { UpdateCenterItem } from "../../../../electron/main/backend/update-center/types"; import type { UpdateCenterItem } from "../../../../electron/main/backend/update-center/types";
import type { UpdateCenterQueue } from "../../../../electron/main/backend/update-center/queue";
import { createUpdateCenterQueue } from "../../../../electron/main/backend/update-center/queue"; import { createUpdateCenterQueue } from "../../../../electron/main/backend/update-center/queue";
import { import {
createUpdateCenterService, createUpdateCenterService,
@@ -31,6 +30,11 @@ const createItem = (): UpdateCenterItem => ({
nextVersion: "2.0.0", nextVersion: "2.0.0",
}); });
const createStartTask = (taskKey: string, id: number) => ({
taskKey,
id,
});
describe("update-center/ipc", () => { describe("update-center/ipc", () => {
beforeEach(() => { beforeEach(() => {
electronMock.getAllWindows.mockReset(); electronMock.getAllWindows.mockReset();
@@ -127,7 +131,10 @@ describe("update-center/ipc", () => {
const startHandler = handle.mock.calls.find( const startHandler = handle.mock.calls.find(
([channel]: [string]) => channel === "update-center-start", ([channel]: [string]) => channel === "update-center-start",
)?.[1] as )?.[1] as
| ((event: unknown, taskKeys: string[]) => Promise<void>) | ((
event: unknown,
tasks: Array<{ taskKey: string; id: number }>,
) => Promise<void>)
| undefined; | undefined;
const cancelHandler = handle.mock.calls.find( const cancelHandler = handle.mock.calls.find(
([channel]: [string]) => channel === "update-center-cancel", ([channel]: [string]) => channel === "update-center-cancel",
@@ -146,12 +153,12 @@ describe("update-center/ipc", () => {
{}, {},
{ packageName: "spark-weather", newVersion: "2.0.0" }, { packageName: "spark-weather", newVersion: "2.0.0" },
); );
await startHandler?.({}, ["aptss:spark-weather"]); await startHandler?.({}, [{ taskKey: "aptss:spark-weather", id: 1 }]);
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",
@@ -160,52 +167,91 @@ describe("update-center/ipc", () => {
packageName: "spark-weather", packageName: "spark-weather",
newVersion: "2.0.0", newVersion: "2.0.0",
}); });
expect(service.start).toHaveBeenCalledWith(["aptss:spark-weather"]); expect(service.start).toHaveBeenCalledWith([
{ taskKey: "aptss:spark-weather", id: 1 },
]);
expect(service.cancel).toHaveBeenCalledWith("aptss:spark-weather"); expect(service.cancel).toHaveBeenCalledWith("aptss:spark-weather");
listener?.(snapshot); listener?.(snapshot);
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 service = createUpdateCenterService({ const service = createUpdateCenterService({
loadItems: async () => [createItem()], loadItems: async () => [createItem()],
loadIgnoredEntries: async () => new Set(ignoredEntries), loadIgnoredEntries: async () => new Set(ignoredEntries),
saveIgnoredEntries: async (entries: ReadonlySet<string>) => { saveIgnoredEntries: async (entries: ReadonlySet<string>) => {
ignoredEntries = new Set(entries); 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[] = []; const snapshots: UpdateCenterServiceState[] = [];
electronMock.getAllWindows.mockReturnValue([{ webContents: { send } }]);
service.subscribe((snapshot: UpdateCenterServiceState) => { service.subscribe((snapshot: UpdateCenterServiceState) => {
snapshots.push(snapshot); snapshots.push(snapshot);
}); });
await service.refresh(); await service.refresh();
await service.start(["aptss:spark-weather"]); await service.start([createStartTask("aptss:spark-weather", 1)]);
await service.ignore({ packageName: "spark-weather", newVersion: "2.0.0" }); await service.ignore({ packageName: "spark-weather", newVersion: "2.0.0" });
expect(snapshots.some((snapshot) => snapshot.hasRunningTasks)).toBe(true); expect(send).toHaveBeenCalledWith(
"queue-install",
expect.stringContaining('"pkgname":"spark-weather"'),
);
expect( expect(
snapshots.some((snapshot) => snapshots.some((snapshot) =>
snapshot.tasks.some( snapshot.items.every(
(task: UpdateCenterServiceState["tasks"][number]) => (item: UpdateCenterServiceState["items"][number]) =>
task.taskKey === "aptss:spark-weather" && item.taskKey !== "aptss:spark-weather",
task.status === "completed",
), ),
), ),
).toBe(true); ).toBe(true);
@@ -215,10 +261,15 @@ describe("update-center/ipc", () => {
newVersion: "2.0.0", newVersion: "2.0.0",
}); });
expect(snapshots.at(-1)?.items[0]).not.toHaveProperty("nextVersion"); expect(snapshots.at(-1)?.items[0]).not.toHaveProperty("nextVersion");
expect(snapshots.every((snapshot) => snapshot.tasks.length === 0)).toBe(
true,
);
expect(
snapshots.every((snapshot) => snapshot.hasRunningTasks === false),
).toBe(true);
}); });
it("service task snapshots keep localIcon and remoteIcon for queued work", async () => { it("service item snapshots keep localIcon and remoteIcon after refresh", async () => {
let releaseTask: (() => void) | undefined;
const service = createUpdateCenterService({ const service = createUpdateCenterService({
loadItems: async () => [ loadItems: async () => [
{ {
@@ -227,39 +278,17 @@ describe("update-center/ipc", () => {
remoteIcon: "https://example.com/weather.png", remoteIcon: "https://example.com/weather.png",
}, },
], ],
createTaskRunner: (queue: UpdateCenterQueue) => ({
cancelActiveTask: vi.fn(),
runNextTask: async () => {
const task = queue.getNextQueuedTask();
if (!task) {
return null;
}
await new Promise<void>((resolve) => {
releaseTask = resolve;
});
queue.markActiveTask(task.id, "installing");
queue.finishTask(task.id, "completed");
return task;
},
}),
}); });
await service.refresh(); await service.refresh();
const startPromise = service.start(["aptss:spark-weather"]);
await flushPromises();
expect(service.getState().tasks).toMatchObject([ expect(service.getState().items).toMatchObject([
{ {
taskKey: "aptss:spark-weather", taskKey: "aptss:spark-weather",
localIcon: "/icons/weather.png", localIcon: "/icons/weather.png",
remoteIcon: "https://example.com/weather.png", remoteIcon: "https://example.com/weather.png",
status: "queued",
}, },
]); ]);
releaseTask?.();
await startPromise;
}); });
it("service item snapshots prefer resolved app names over package names", async () => { it("service item snapshots prefer resolved app names over package names", async () => {
@@ -283,178 +312,85 @@ describe("update-center/ipc", () => {
]); ]);
}); });
it("concurrent start calls still serialize through one processing pipeline", async () => { it("start forwards selected updates to the main download queue", async () => {
const startedTaskIds: number[] = []; const send = vi.fn();
const releases: Array<() => void> = [];
const service = createUpdateCenterService({ const service = createUpdateCenterService({
loadItems: async () => [ loadItems: async () => [
createItem(), createItem(),
{ ...createItem(), pkgname: "spark-clock" }, { ...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;
},
}),
}); });
electronMock.getAllWindows.mockReturnValue([{ webContents: { send } }]);
await service.refresh(); await service.refresh();
await service.start([
const firstStart = service.start(["aptss:spark-weather"]); createStartTask("aptss:spark-weather", 1),
const secondStart = service.start(["aptss:spark-clock"]); createStartTask("aptss:spark-clock", 2),
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" },
]); ]);
expect(send).toHaveBeenCalledTimes(2);
expect(service.getState().items).toEqual([]);
}); });
it("cancelling an active task stops it and leaves it cancelled", async () => { it("cancel is a no-op for update-center tasks", async () => {
let releaseTask: (() => void) | undefined;
const cancelActiveTask = vi.fn(() => {
releaseTask?.();
});
const service = createUpdateCenterService({ const service = createUpdateCenterService({
loadItems: async () => [createItem()], 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(); await service.refresh();
const startPromise = service.start(["aptss:spark-weather"]);
await flushPromises();
await service.cancel("aptss:spark-weather"); await service.cancel("aptss:spark-weather");
await startPromise;
expect(cancelActiveTask).toHaveBeenCalledTimes(1); expect(service.getState()).toMatchObject({
expect(service.getState().tasks).toMatchObject([ items: [{ taskKey: "aptss:spark-weather" }],
{ taskKey: "aptss:spark-weather", status: "cancelled" }, tasks: [],
]); hasRunningTasks: false,
});
}); });
it("cancelling a queued task does not abort the currently active task", async () => { it("start without a main window leaves updates actionable", async () => {
let releaseTask: (() => void) | undefined;
const cancelActiveTask = vi.fn(() => {
releaseTask?.();
});
const service = createUpdateCenterService({ const service = createUpdateCenterService({
loadItems: async () => [ loadItems: async () => [
createItem(), createItem(),
{ ...createItem(), pkgname: "spark-clock" }, { ...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;
},
}),
}); });
electronMock.getAllWindows.mockReturnValue([]);
await service.refresh(); await service.refresh();
const activeStart = service.start(["aptss:spark-weather"]); await service.start([createStartTask("aptss:spark-weather", 1)]);
await flushPromises();
const queuedStart = service.start(["aptss:spark-clock"]);
await flushPromises();
await service.cancel("aptss:spark-clock"); expect(service.getState().items).toMatchObject([
expect(cancelActiveTask).not.toHaveBeenCalled(); { taskKey: "aptss:spark-weather" },
{ taskKey: "aptss:spark-clock" },
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 () => { it("ignored items are not forwarded to the main download queue", async () => {
const send = vi.fn();
const service = createUpdateCenterService({ const service = createUpdateCenterService({
loadItems: async () => [createItem()], loadItems: async () => [createItem()],
superUserCmdProvider: async () => { loadIgnoredEntries: async () => new Set(["spark-weather|2.0.0"]),
throw new Error("pkexec unavailable");
},
createTaskRunner: () => ({
cancelActiveTask: vi.fn(),
runNextTask: async () => {
throw new Error(
"runner should not start when privilege lookup fails",
);
},
}),
}); });
electronMock.getAllWindows.mockReturnValue([{ webContents: { send } }]);
await service.refresh(); await service.refresh();
await service.start(["aptss:spark-weather"]); await service.start([createStartTask("aptss:spark-weather", 1)]);
expect(service.getState()).toMatchObject({ expect(service.getState()).toMatchObject({
hasRunningTasks: false, hasRunningTasks: false,
tasks: [ items: [
{ {
taskKey: "aptss:spark-weather", taskKey: "aptss:spark-weather",
status: "failed", ignored: true,
errorMessage: "pkexec unavailable",
}, },
], ],
tasks: [],
}); });
expect(send).not.toHaveBeenCalled();
}); });
it("refresh exposes load-item failures as warnings", async () => { it("refresh exposes load-item failures as warnings", async () => {
+54 -6
View File
@@ -24,6 +24,8 @@ const createSnapshot = (overrides = {}) => ({
describe("updateCenter store", () => { describe("updateCenter store", () => {
const open = vi.fn(); const open = vi.fn();
const refresh = vi.fn(); const refresh = vi.fn();
const ignore = vi.fn();
const unignore = vi.fn();
const start = vi.fn(); const start = vi.fn();
const onState = vi.fn(); const onState = vi.fn();
const offState = vi.fn(); const offState = vi.fn();
@@ -31,6 +33,8 @@ describe("updateCenter store", () => {
beforeEach(() => { beforeEach(() => {
open.mockReset(); open.mockReset();
refresh.mockReset(); refresh.mockReset();
ignore.mockReset();
unignore.mockReset();
start.mockReset(); start.mockReset();
onState.mockReset(); onState.mockReset();
offState.mockReset(); offState.mockReset();
@@ -41,8 +45,8 @@ describe("updateCenter store", () => {
value: { value: {
open, open,
refresh, refresh,
ignore: vi.fn(), ignore,
unignore: vi.fn(), unignore,
start, start,
cancel: vi.fn(), cancel: vi.fn(),
getState: vi.fn(), getState: vi.fn(),
@@ -57,14 +61,37 @@ describe("updateCenter store", () => {
open.mockResolvedValue(snapshot); open.mockResolvedValue(snapshot);
const store = createUpdateCenterStore(); const store = createUpdateCenterStore();
await store.open(); const openPromise = store.open("apm");
expect(open).toHaveBeenCalledTimes(1);
expect(store.isOpen.value).toBe(true); expect(store.isOpen.value).toBe(true);
expect(store.loading.value).toBe(true);
await openPromise;
expect(open).toHaveBeenCalledWith("apm");
expect(store.isOpen.value).toBe(true);
expect(store.loading.value).toBe(false);
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);
}); });
it("reuses the last store filter when refreshing without an explicit filter", async () => {
const snapshot = createSnapshot();
open.mockResolvedValue(snapshot);
refresh.mockResolvedValue(snapshot);
const store = createUpdateCenterStore();
await store.open("apm");
const refreshPromise = store.refresh();
expect(store.loading.value).toBe(true);
await refreshPromise;
expect(store.loading.value).toBe(false);
expect(refresh).toHaveBeenCalledWith("apm");
});
it("starts only the selected non-ignored items", async () => { it("starts only the selected non-ignored items", async () => {
const snapshot = createSnapshot({ const snapshot = createSnapshot({
items: [ items: [
@@ -132,6 +159,25 @@ describe("updateCenter store", () => {
); );
}); });
it("forwards ignore and unignore actions with the package and target version", async () => {
const snapshot = createSnapshot();
open.mockResolvedValue(snapshot);
const store = createUpdateCenterStore();
await store.open();
await store.ignoreItem("spark-weather", "2.0.0");
await store.unignoreItem("spark-weather", "2.0.0");
expect(ignore).toHaveBeenCalledWith({
packageName: "spark-weather",
newVersion: "2.0.0",
});
expect(unignore).toHaveBeenCalledWith({
packageName: "spark-weather",
newVersion: "2.0.0",
});
});
it("assigns update-center download ids from a separate range", async () => { it("assigns update-center download ids from a separate range", async () => {
downloads.value = [ downloads.value = [
{ {
@@ -174,12 +220,14 @@ describe("updateCenter store", () => {
it("blocks close requests while the snapshot reports running tasks", () => { it("blocks close requests while the snapshot reports running tasks", () => {
const store = createUpdateCenterStore(); const store = createUpdateCenterStore();
store.isOpen.value = true; store.isOpen.value = true;
store.loading.value = true;
store.snapshot.value = createSnapshot({ hasRunningTasks: true }); store.snapshot.value = createSnapshot({ hasRunningTasks: true });
store.requestClose(); store.requestClose();
expect(store.isOpen.value).toBe(true); expect(store.isOpen.value).toBe(false);
expect(store.showCloseConfirm.value).toBe(true); expect(store.loading.value).toBe(false);
expect(store.showCloseConfirm.value).toBe(false);
}); });
it("applies pushed snapshots from the main process", () => { it("applies pushed snapshots from the main process", () => {
+81
View File
@@ -0,0 +1,81 @@
<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-[80] flex items-center justify-center bg-slate-900/70 p-4"
@click.self="handleClose"
>
<div
class="relative w-full max-w-lg overflow-hidden rounded-3xl border border-white/10 bg-white/95 p-6 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
>
<div class="mb-6 flex items-center gap-4">
<div
class="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-brand/20 to-brand/10 shadow-inner dark:from-brand/20 dark:to-brand/10"
>
<i class="fas fa-box-open text-2xl text-brand"></i>
</div>
<div>
<h3 class="text-xl font-bold text-slate-900 dark:text-white">
需要安装 APM
</h3>
<p class="text-sm text-slate-500 dark:text-slate-400">
APM 是星火应用商店的软件包兼容工具此应用使用星火 APM 提供支持
</p>
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">
是否前往商店安装
<span class="font-semibold text-slate-700 dark:text-slate-200"
>APM</span
>
</p>
</div>
</div>
<div class="flex items-center justify-end gap-3">
<button
type="button"
class="rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
@click="handleClose"
>
取消
</button>
<button
type="button"
class="inline-flex items-center gap-2 rounded-xl bg-gradient-to-r from-brand to-brand-dark px-4 py-2 text-sm font-semibold text-white shadow-lg shadow-brand/30 transition hover:-translate-y-0.5"
@click="handleConfirm"
>
<i class="fas fa-download"></i>
前往安装
</button>
</div>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
defineProps<{
show: boolean;
}>();
const emit = defineEmits<{
(e: "close"): void;
(e: "confirm"): void;
}>();
const handleClose = () => {
emit("close");
};
const handleConfirm = () => {
emit("confirm");
};
</script>
+69 -72
View File
@@ -10,7 +10,7 @@
<div <div
v-if="show" v-if="show"
v-bind="attrs" v-bind="attrs"
class="fixed inset-0 z-50 flex items-center justify-center overflow-hidden bg-slate-900/70 p-4" class="fixed inset-0 z-[70] flex items-center justify-center overflow-hidden bg-slate-900/70 p-4"
@click.self="closeModal" @click.self="closeModal"
@wheel="onOverlayWheel" @wheel="onOverlayWheel"
> >
@@ -50,90 +50,87 @@
<h2 class="mt-4 text-xl font-bold text-slate-900 dark:text-white"> <h2 class="mt-4 text-xl font-bold text-slate-900 dark:text-white">
{{ displayApp?.name || "" }} {{ displayApp?.name || "" }}
</h2> </h2>
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1"> <p class="mt-1 text-sm text-slate-500 dark:text-slate-400">
{{ displayApp?.pkgname || "" }} {{ displayApp?.pkgname || "" }}
</p> </p>
</div>
<!-- 版本号和来源切换 -->
<div class="space-y-3">
<!-- 版本号 -->
<div <div
class="flex items-center justify-between rounded-2xl border border-slate-200/60 bg-slate-50/50 px-4 py-3 dark:border-slate-800/60 dark:bg-slate-800/50" v-if="displayApp?.version || downloadCount"
class="mt-1 flex flex-wrap items-center justify-center gap-x-3 gap-y-1 text-sm text-slate-500 dark:text-slate-400"
> >
<span class="text-sm text-slate-500 dark:text-slate-400" <span v-if="displayApp?.version">{{ displayApp.version }}</span>
>版本</span
>
<span <span
class="text-sm font-semibold text-slate-800 dark:text-slate-200" v-if="displayApp?.version && downloadCount"
>{{ displayApp?.version || "-" }}</span class="text-slate-300 dark:text-slate-600"
>·</span
> >
</div> <span v-if="downloadCount" class="flex items-center gap-1">
<svg
<!-- 应用来源切换 --> class="w-3.5 h-3.5"
<div fill="none"
class="flex items-center justify-between rounded-2xl border border-slate-200/60 bg-slate-50/50 px-4 py-3 dark:border-slate-800/60 dark:bg-slate-800/50" stroke="currentColor"
> viewBox="0 0 24 24"
<span class="text-sm text-slate-500 dark:text-slate-400"
>来源</span
>
<div
v-if="app?.isMerged"
class="flex gap-1 overflow-hidden rounded-lg shadow-sm border border-slate-200 dark:border-slate-700"
>
<button
v-if="app.sparkApp"
type="button"
class="px-3 py-1 text-xs font-medium uppercase tracking-wider transition-colors"
:class="
viewingOrigin === 'spark'
? 'bg-orange-500 text-white'
: 'bg-slate-100 text-slate-500 dark:bg-slate-700 dark:text-slate-400 hover:bg-slate-200 dark:hover:bg-slate-600'
"
@click="viewingOrigin = 'spark'"
> >
Spark <path
</button> stroke-linecap="round"
<button stroke-linejoin="round"
v-if="app.apmApp" stroke-width="2"
type="button" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
class="px-3 py-1 text-xs font-medium uppercase tracking-wider transition-colors" />
:class=" </svg>
viewingOrigin === 'apm' {{ downloadCount }}
? 'bg-blue-500 text-white'
: 'bg-slate-100 text-slate-500 dark:bg-slate-700 dark:text-slate-400 hover:bg-slate-200 dark:hover:bg-slate-600'
"
@click="viewingOrigin = 'apm'"
>
APM
</button>
</div>
<span
v-else-if="displayApp"
:class="[
'rounded-md px-2 py-1 text-xs font-bold uppercase tracking-wider',
displayApp.origin === 'spark'
? 'bg-orange-100 text-orange-600 dark:bg-orange-900/30 dark:text-orange-400'
: 'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400',
]"
>
{{ displayApp.origin === "spark" ? "Spark" : "APM" }}
</span> </span>
</div> </div>
</div>
<!-- 下载量 --> <!-- 应用来源切换 -->
<div <div
v-if="downloadCount" class="flex items-center justify-between rounded-2xl border border-slate-200/60 bg-slate-50/50 px-4 py-3 dark:border-slate-800/60 dark:bg-slate-800/50"
class="flex items-center justify-between rounded-2xl border border-slate-200/60 bg-slate-50/50 px-4 py-3 dark:border-slate-800/60 dark:bg-slate-800/50" >
<span class="text-sm text-slate-500 dark:text-slate-400"
>来源</span
> >
<span class="text-sm text-slate-500 dark:text-slate-400" <div
>下载量</span v-if="app?.isMerged"
class="flex gap-1 overflow-hidden rounded-lg shadow-sm border border-slate-200 dark:border-slate-700"
>
<button
v-if="app.sparkApp"
type="button"
class="px-3 py-1 text-xs font-medium uppercase tracking-wider transition-colors"
:class="
viewingOrigin === 'spark'
? 'bg-orange-500 text-white'
: 'bg-slate-100 text-slate-500 dark:bg-slate-700 dark:text-slate-400 hover:bg-slate-200 dark:hover:bg-slate-600'
"
@click="viewingOrigin = 'spark'"
> >
<span Spark
class="text-sm font-semibold text-slate-800 dark:text-slate-200" </button>
>{{ downloadCount }}</span <button
v-if="app.apmApp"
type="button"
class="px-3 py-1 text-xs font-medium uppercase tracking-wider transition-colors"
:class="
viewingOrigin === 'apm'
? 'bg-blue-500 text-white'
: 'bg-slate-100 text-slate-500 dark:bg-slate-700 dark:text-slate-400 hover:bg-slate-200 dark:hover:bg-slate-600'
"
@click="viewingOrigin = 'apm'"
> >
APM
</button>
</div> </div>
<span
v-else-if="displayApp"
:class="[
'rounded-md px-2 py-1 text-xs font-bold uppercase tracking-wider',
displayApp.origin === 'spark'
? 'bg-orange-100 text-orange-600 dark:bg-orange-900/30 dark:text-orange-400'
: 'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400',
]"
>
{{ displayApp.origin === "spark" ? "Spark" : "APM" }}
</span>
</div> </div>
<!-- 功能按钮 --> <!-- 功能按钮 -->
@@ -332,7 +329,7 @@
> >
<div <div
v-if="showMetaModal" v-if="showMetaModal"
class="fixed inset-0 z-[60] flex items-center justify-center overflow-hidden bg-slate-900/60 p-4" class="fixed inset-0 z-[80] flex items-center justify-center overflow-hidden bg-slate-900/60 p-4"
@click.self="closeMetaModal" @click.self="closeMetaModal"
> >
<div <div
+20 -6
View File
@@ -18,11 +18,20 @@
<input <input
id="searchBox" id="searchBox"
v-model="localSearchQuery" v-model="localSearchQuery"
class="w-full rounded-2xl border border-slate-200/70 bg-white/80 py-3 pl-12 pr-24 text-sm text-slate-700 shadow-sm outline-none transition placeholder:text-slate-400 focus:border-brand/50 focus:ring-4 focus:ring-brand/10 dark:border-slate-800/70 dark:bg-slate-900/60 dark:text-slate-200" class="w-full rounded-2xl border border-slate-200/70 bg-white/80 py-3 pl-12 pr-20 text-sm text-slate-700 shadow-sm outline-none transition placeholder:text-slate-400 focus:border-brand/50 focus:ring-4 focus:ring-brand/10 dark:border-slate-800/70 dark:bg-slate-900/60 dark:text-slate-200"
placeholder="搜索应用名 / 包名 / 标签,按回车键搜索" placeholder="搜索应用名 / 包名 / 标签"
@keydown.enter="handleSearch"
@focus="handleSearchFocus" @focus="handleSearchFocus"
@input="handleInput"
/> />
<button
v-if="localSearchQuery"
type="button"
class="absolute right-4 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-300 transition-colors"
@click="clearSearch"
title="清除搜索"
>
<i class="fas fa-times-circle"></i>
</button>
</div> </div>
<button <button
type="button" type="button"
@@ -70,12 +79,17 @@ const emit = defineEmits<{
const localSearchQuery = ref(props.searchQuery || ""); const localSearchQuery = ref(props.searchQuery || "");
const handleSearch = () => { const handleSearchFocus = () => {
emit("search-focus");
};
const handleInput = () => {
emit("update-search", localSearchQuery.value); emit("update-search", localSearchQuery.value);
}; };
const handleSearchFocus = () => { const clearSearch = () => {
emit("search-focus"); localSearchQuery.value = "";
emit("update-search", "");
}; };
watch( watch(
+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);
}; };
+61 -13
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="
@@ -60,7 +62,7 @@
</div> </div>
<button <button
type="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" 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 dark:hover:bg-slate-800"
:disabled="loading" :disabled="loading"
@click="$emit('refresh')" @click="$emit('refresh')"
> >
@@ -69,7 +71,7 @@
</button> </button>
<button <button
type="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" 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:hover:text-white dark:border-slate-700 dark:hover:bg-slate-800"
@click="$emit('close')" @click="$emit('close')"
aria-label="关闭" aria-label="关闭"
> >
@@ -146,15 +148,36 @@
</div> </div>
</div> </div>
</div> </div>
<button <div
type="button" class="flex flex-wrap items-center justify-end gap-2 sm:min-w-[22rem]"
class="inline-flex items-center gap-2 rounded-2xl border border-rose-300/60 px-4 py-2 text-sm font-semibold text-rose-600 transition hover:bg-rose-50 disabled:opacity-50"
:disabled="app.currentStatus === 'not-installed'"
@click="$emit('uninstall', app)"
> >
<i class="fas fa-trash"></i> <button
卸载 type="button"
</button> class="inline-flex items-center gap-2 rounded-2xl border border-slate-300/70 px-4 py-2 text-sm font-semibold text-slate-700 transition hover:bg-slate-50 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
@click="$emit('open-app', app)"
>
<i class="fas fa-play"></i>
打开
</button>
<button
v-if="canOpenDetail(app)"
type="button"
class="inline-flex items-center gap-2 rounded-2xl border border-brand/30 px-4 py-2 text-sm font-semibold text-brand transition hover:bg-brand/10"
@click="$emit('open-detail', app)"
>
<i class="fas fa-circle-info"></i>
查看详情
</button>
<button
type="button"
class="inline-flex items-center gap-2 rounded-2xl border border-rose-300/60 px-4 py-2 text-sm font-semibold text-rose-600 transition hover:bg-rose-50 disabled:opacity-50"
:disabled="app.currentStatus === 'not-installed'"
@click="$emit('uninstall', app)"
>
<i class="fas fa-trash"></i>
卸载
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -164,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";
@@ -178,13 +201,24 @@ const getIconUrl = (app: App) => {
return `${APM_STORE_BASE_URL}/${finalArch}/${app.category}/${app.pkgname}/icon.png`; return `${APM_STORE_BASE_URL}/${finalArch}/${app.category}/${app.pkgname}/icon.png`;
}; };
defineProps<{ const canOpenDetail = (app: App) => {
return (
app.category !== "unknown" ||
Boolean(app.more) ||
Boolean(app.website) ||
Boolean(app.author) ||
(app.img_urls?.length ?? 0) > 0
);
};
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;
}>(); }>();
@@ -193,6 +227,8 @@ defineEmits<{
(e: "refresh"): void; (e: "refresh"): void;
(e: "uninstall", app: App): void; (e: "uninstall", app: App): void;
(e: "switch-origin", origin: "apm" | "spark"): void; (e: "switch-origin", origin: "apm" | "spark"): void;
(e: "open-app", app: App): void;
(e: "open-detail", app: App): void;
}>(); }>();
const onOverlayWheel = (e: WheelEvent) => { const onOverlayWheel = (e: WheelEvent) => {
@@ -200,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>
+1 -1
View File
@@ -9,7 +9,7 @@
> >
<div <div
v-if="show" v-if="show"
class="fixed inset-0 z-[60] flex items-center justify-center bg-slate-900/80 px-4 py-10" class="fixed inset-0 z-[80] flex items-center justify-center bg-slate-900/80 px-4 py-10"
@click.self="closePreview" @click.self="closePreview"
> >
<div class="relative w-full max-w-5xl"> <div class="relative w-full max-w-5xl">
+220
View File
@@ -0,0 +1,220 @@
<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-[70] flex items-center justify-center overflow-hidden bg-slate-900/70 p-4"
@click.self="closeModal"
>
<div
class="relative w-full max-w-md overflow-hidden rounded-3xl border border-white/10 bg-white/95 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
>
<!-- 标题栏 -->
<div
class="flex items-center justify-between border-b border-slate-200/60 px-6 py-4 dark:border-slate-800/60"
>
<h2
class="text-lg font-bold text-slate-900 dark:text-white flex items-center gap-2"
>
<i class="fas fa-cog text-brand"></i>
安装设置
</h2>
<button
type="button"
class="inline-flex h-8 w-8 items-center justify-center rounded-full text-slate-400 transition hover:bg-slate-100 hover:text-slate-600 dark:hover:bg-slate-800 dark:hover:text-slate-300"
@click="closeModal"
aria-label="关闭"
>
<i class="fas fa-times"></i>
</button>
</div>
<!-- 设置内容 -->
<div class="p-6 space-y-4">
<!-- 更新检测开关 -->
<div
class="flex items-center justify-between rounded-2xl border border-slate-200/60 bg-slate-50/50 px-4 py-4 dark:border-slate-800/60 dark:bg-slate-800/50"
>
<div class="flex items-center gap-3">
<div
class="flex h-10 w-10 items-center justify-center rounded-xl bg-orange-100 text-orange-600 dark:bg-orange-900/30 dark:text-orange-400"
>
<i class="fas fa-bell"></i>
</div>
<div>
<p
class="text-sm font-medium text-slate-800 dark:text-slate-200"
>
更新检测通知
</p>
<p class="text-xs text-slate-500 dark:text-slate-400">
系统启动后自动检测更新并通知
</p>
</div>
</div>
<button
type="button"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2"
:class="
settings.enableUpdateCheck
? 'bg-brand'
: 'bg-slate-300 dark:bg-slate-600'
"
@click="toggleUpdateCheck"
>
<span
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
:class="
settings.enableUpdateCheck ? 'translate-x-6' : 'translate-x-1'
"
/>
</button>
</div>
<!-- 自动创建桌面启动器开关 -->
<div
class="flex items-center justify-between rounded-2xl border border-slate-200/60 bg-slate-50/50 px-4 py-4 dark:border-slate-800/60 dark:bg-slate-800/50"
>
<div class="flex items-center gap-3">
<div
class="flex h-10 w-10 items-center justify-center rounded-xl bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400"
>
<i class="fas fa-desktop"></i>
</div>
<div>
<p
class="text-sm font-medium text-slate-800 dark:text-slate-200"
>
自动创建桌面启动器
</p>
<p class="text-xs text-slate-500 dark:text-slate-400">
安装应用时自动创建桌面图标
</p>
</div>
</div>
<button
type="button"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2"
:class="
settings.enableCreateDesktop
? 'bg-brand'
: 'bg-slate-300 dark:bg-slate-600'
"
@click="toggleCreateDesktop"
>
<span
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
:class="
settings.enableCreateDesktop
? 'translate-x-6'
: 'translate-x-1'
"
/>
</button>
</div>
</div>
<!-- 底部提示 -->
<div
class="border-t border-slate-200/60 bg-slate-50/50 px-6 py-3 dark:border-slate-800/60 dark:bg-slate-800/30"
>
<p class="text-xs text-slate-500 dark:text-slate-400 text-center">
设置会自动保存
</p>
</div>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from "vue";
const props = defineProps<{
show: boolean;
}>();
const emit = defineEmits<{
(e: "close"): void;
}>();
interface Settings {
enableUpdateCheck: boolean;
enableCreateDesktop: boolean;
}
const settings = ref<Settings>({
enableUpdateCheck: true,
enableCreateDesktop: true,
});
//
const UPDATE_CHECK_CONFIG = "ssshell-config-do-not-show-upgrade-notify";
const CREATE_DESKTOP_CONFIG = "ssshell-config-do-not-create-desktop";
//
const loadSettings = async () => {
try {
const result = await window.ipcRenderer.invoke("get-install-settings");
if (result.success) {
settings.value = {
enableUpdateCheck: !result.data[UPDATE_CHECK_CONFIG],
enableCreateDesktop: !result.data[CREATE_DESKTOP_CONFIG],
};
}
} catch (error) {
console.error("加载设置失败:", error);
}
};
//
const saveSettings = async () => {
try {
await window.ipcRenderer.invoke("set-install-settings", {
[UPDATE_CHECK_CONFIG]: !settings.value.enableUpdateCheck,
[CREATE_DESKTOP_CONFIG]: !settings.value.enableCreateDesktop,
});
} catch (error) {
console.error("保存设置失败:", error);
}
};
//
const toggleUpdateCheck = () => {
settings.value.enableUpdateCheck = !settings.value.enableUpdateCheck;
saveSettings();
};
//
const toggleCreateDesktop = () => {
settings.value.enableCreateDesktop = !settings.value.enableCreateDesktop;
saveSettings();
};
//
const closeModal = () => {
emit("close");
};
//
watch(
() => props.show,
(newVal) => {
if (newVal) {
loadSettings();
}
},
);
onMounted(() => {
if (props.show) {
loadSettings();
}
});
</script>
+1 -1
View File
@@ -9,7 +9,7 @@
> >
<div <div
v-if="show" v-if="show"
class="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/70 p-4" class="fixed inset-0 z-[80] flex items-center justify-center bg-slate-900/70 p-4"
@click.self="handleClose" @click.self="handleClose"
> >
<div <div
+29 -7
View File
@@ -22,6 +22,7 @@
:selected-count="selectedCount" :selected-count="selectedCount"
:all-selected="store.allSelected.value" :all-selected="store.allSelected.value"
:some-selected="store.someSelected.value" :some-selected="store.someSelected.value"
:loading="store.loading.value"
@refresh="store.refresh" @refresh="store.refresh"
@start-selected="emit('request-start-selected')" @start-selected="emit('request-start-selected')"
@request-close="store.requestClose" @request-close="store.requestClose"
@@ -42,14 +43,35 @@
</p> </p>
</div> </div>
<div class="min-h-0 flex-1"> <div
<UpdateCenterList v-if="store.loading.value && store.filteredItems.value.length === 0"
:items="store.filteredItems.value" class="flex min-h-0 flex-1 items-center justify-center p-6"
:tasks="store.snapshot.value.tasks" >
:selected-task-keys="store.selectedTaskKeys.value" <div
@toggle-selection="emit('toggle-selection', $event)" class="flex flex-col items-center gap-3 text-slate-500 dark:text-slate-400"
/> >
<i class="fas fa-circle-notch fa-spin text-3xl"></i>
<p class="text-sm">正在检查更新</p>
</div>
</div> </div>
<template v-else>
<div
v-if="store.loading.value && store.filteredItems.value.length > 0"
class="border-b border-slate-200/70 px-6 py-2 text-center text-xs text-slate-400 dark:border-slate-800/70 dark:text-slate-500"
>
正在刷新更新列表
</div>
<div class="flex min-h-0 flex-1">
<UpdateCenterList
:items="store.filteredItems.value"
:tasks="store.snapshot.value.tasks"
:selected-task-keys="store.selectedTaskKeys.value"
@toggle-selection="emit('toggle-selection', $event)"
@ignore-item="store.ignoreItem"
@unignore-item="store.unignoreItem"
/>
</div>
</template>
<UpdateCenterMigrationConfirm <UpdateCenterMigrationConfirm
:show="store.showMigrationConfirm.value" :show="store.showMigrationConfirm.value"
@@ -63,6 +63,29 @@
</div> </div>
</div> </div>
<div class="flex justify-end">
<button
v-if="item.ignored === true"
type="button"
class="inline-flex items-center gap-2 rounded-2xl border border-slate-300/80 px-3 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"
aria-label="取消忽略"
@click.stop="$emit('unignore-item')"
>
<i class="fas fa-rotate-left"></i>
取消忽略
</button>
<button
v-else
type="button"
class="inline-flex items-center gap-2 rounded-2xl border border-amber-300/80 px-3 py-2 text-sm font-semibold text-amber-700 transition hover:bg-amber-50 dark:border-amber-500/40 dark:text-amber-300 dark:hover:bg-amber-500/10"
aria-label="忽略更新"
@click.stop="$emit('ignore-item')"
>
<i class="fas fa-eye-slash"></i>
忽略更新
</button>
</div>
<div v-if="showProgress" class="space-y-2"> <div v-if="showProgress" class="space-y-2">
<div <div
class="h-2 overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800" class="h-2 overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800"
@@ -96,6 +119,8 @@ const iconIndex = ref(0);
defineEmits<{ defineEmits<{
(e: "toggle-selection"): void; (e: "toggle-selection"): void;
(e: "ignore-item"): void;
(e: "unignore-item"): void;
}>(); }>();
const normalizeIconSrc = (icon: string): string => { const normalizeIconSrc = (icon: string): string => {
@@ -1,6 +1,6 @@
<template> <template>
<div <div
class="min-h-0 overflow-y-auto overscroll-contain scrollbar-muted border-r border-slate-200/70 p-6 dark:border-slate-800/70" class="min-h-0 flex-1 overflow-y-auto overscroll-contain scrollbar-muted border-r border-slate-200/70 p-6 dark:border-slate-800/70"
> >
<div <div
v-if="items.length === 0" v-if="items.length === 0"
@@ -16,6 +16,10 @@
:task="taskMap.get(item.taskKey)" :task="taskMap.get(item.taskKey)"
:selected="selectedTaskKeys.has(item.taskKey)" :selected="selectedTaskKeys.has(item.taskKey)"
@toggle-selection="$emit('toggle-selection', item.taskKey)" @toggle-selection="$emit('toggle-selection', item.taskKey)"
@ignore-item="$emit('ignore-item', item.packageName, item.newVersion)"
@unignore-item="
$emit('unignore-item', item.packageName, item.newVersion)
"
/> />
</div> </div>
</div> </div>
@@ -39,6 +43,8 @@ const props = defineProps<{
defineEmits<{ defineEmits<{
(e: "toggle-selection", taskKey: string): void; (e: "toggle-selection", taskKey: string): void;
(e: "ignore-item", packageName: string, newVersion: string): void;
(e: "unignore-item", packageName: string, newVersion: string): void;
}>(); }>();
const taskMap = computed(() => { const taskMap = computed(() => {
@@ -14,11 +14,12 @@
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button <button
type="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" 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 dark:hover:bg-slate-800"
:disabled="loading"
@click="$emit('refresh')" @click="$emit('refresh')"
> >
<i class="fas fa-sync-alt"></i> <i class="fas fa-sync-alt" :class="{ 'animate-spin': loading }"></i>
刷新 {{ loading ? "刷新中" : "刷新" }}
</button> </button>
<button <button
type="button" type="button"
@@ -32,7 +33,7 @@
<button <button
type="button" type="button"
aria-label="关闭" 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" 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:hover:text-white dark:border-slate-700 dark:hover:bg-slate-800"
@click="$emit('request-close')" @click="$emit('request-close')"
> >
<i class="fas fa-xmark"></i> <i class="fas fa-xmark"></i>
@@ -58,7 +59,7 @@
</span> </span>
</div> </div>
<label class="block"> <label class="block relative">
<span class="sr-only">搜索更新</span> <span class="sr-only">搜索更新</span>
<input <input
:value="searchQuery" :value="searchQuery"
@@ -67,6 +68,15 @@
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" 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" @input="handleInput"
/> />
<button
v-if="searchQuery"
type="button"
class="absolute right-4 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-300 transition-colors"
@click="clearSearch"
title="清除搜索"
>
<i class="fas fa-times-circle"></i>
</button>
</label> </label>
</div> </div>
</template> </template>
@@ -79,6 +89,7 @@ const props = defineProps<{
selectedCount: number; selectedCount: number;
allSelected: boolean; allSelected: boolean;
someSelected: boolean; someSelected: boolean;
loading?: boolean;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@@ -106,4 +117,8 @@ const handleInput = (event: Event): void => {
const target = event.target as HTMLInputElement | null; const target = event.target as HTMLInputElement | null;
emit("update:search-query", target?.value ?? props.searchQuery); emit("update:search-query", target?.value ?? props.searchQuery);
}; };
const clearSearch = () => {
emit("update:search-query", "");
};
</script> </script>
+1
View File
@@ -11,6 +11,7 @@ export const APM_STORE_STATS_BASE_URL: string =
export const currentApp = ref<App | null>(null); export const currentApp = ref<App | null>(null);
export const currentAppSparkInstalled = ref(false); export const currentAppSparkInstalled = ref(false);
export const currentAppApmInstalled = ref(false); export const currentAppApmInstalled = ref(false);
export const showApmInstallDialog = ref(false);
export const currentStoreMode = ref<StoreMode>("hybrid"); export const currentStoreMode = ref<StoreMode>("hybrid");
+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;
+5 -16
View File
@@ -5,6 +5,7 @@ import {
currentApp, currentApp,
currentAppSparkInstalled, currentAppSparkInstalled,
currentAppApmInstalled, currentAppApmInstalled,
showApmInstallDialog,
} from "../global/storeConfig"; } from "../global/storeConfig";
import { APM_STORE_BASE_URL } from "../global/storeConfig"; import { APM_STORE_BASE_URL } from "../global/storeConfig";
import { downloads, getNextDownloadId } from "../global/downloadStatus"; import { downloads, getNextDownloadId } from "../global/downloadStatus";
@@ -28,14 +29,8 @@ export const handleInstall = async (appObj?: App) => {
if (targetApp.origin === "apm") { if (targetApp.origin === "apm") {
const hasApm = await window.ipcRenderer.invoke("check-apm-available"); const hasApm = await window.ipcRenderer.invoke("check-apm-available");
if (!hasApm) { if (!hasApm) {
// 发送事件到主进程显示 APM 安装对话框 showApmInstallDialog.value = true;
const { success, cancelled } = await window.ipcRenderer.invoke( return;
"show-apm-install-dialog",
);
if (!success || cancelled) {
// 用户取消或未安装成功,不继续安装应用
return;
}
} }
} }
@@ -119,14 +114,8 @@ export const handleUpgrade = async (app: App) => {
if (app.origin === "apm") { if (app.origin === "apm") {
const hasApm = await window.ipcRenderer.invoke("check-apm-available"); const hasApm = await window.ipcRenderer.invoke("check-apm-available");
if (!hasApm) { if (!hasApm) {
// 发送事件到主进程显示 APM 安装对话框 showApmInstallDialog.value = true;
const { success, cancelled } = await window.ipcRenderer.invoke( return;
"show-apm-install-dialog",
);
if (!success || cancelled) {
// 用户取消或未安装成功,不继续更新应用
return;
}
} }
} }
+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);
};
+46 -8
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";
@@ -18,6 +19,7 @@ const EMPTY_SNAPSHOT: UpdateCenterSnapshot = {
export interface UpdateCenterStore { export interface UpdateCenterStore {
isOpen: Ref<boolean>; isOpen: Ref<boolean>;
loading: Ref<boolean>;
showCloseConfirm: Ref<boolean>; showCloseConfirm: Ref<boolean>;
showMigrationConfirm: Ref<boolean>; showMigrationConfirm: Ref<boolean>;
searchQuery: Ref<string>; searchQuery: Ref<string>;
@@ -28,8 +30,10 @@ 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>;
unignoreItem: (packageName: string, newVersion: string) => Promise<void>;
toggleSelection: (taskKey: string) => void; toggleSelection: (taskKey: string) => void;
toggleSelectAll: () => void; toggleSelectAll: () => void;
getSelectedItems: () => UpdateCenterItem[]; getSelectedItems: () => UpdateCenterItem[];
@@ -51,11 +55,13 @@ const matchesSearch = (item: UpdateCenterItem, query: string): boolean => {
export const createUpdateCenterStore = (): UpdateCenterStore => { export const createUpdateCenterStore = (): UpdateCenterStore => {
const isOpen = ref(false); const isOpen = ref(false);
const loading = ref(false);
const showCloseConfirm = ref(false); const showCloseConfirm = ref(false);
const showMigrationConfirm = ref(false); const showMigrationConfirm = ref(false);
const searchQuery = ref(""); const searchQuery = ref("");
const selectedTaskKeys = ref(new Set<string>()); const selectedTaskKeys = ref(new Set<string>());
const snapshot = ref<UpdateCenterSnapshot>(EMPTY_SNAPSHOT); const snapshot = ref<UpdateCenterSnapshot>(EMPTY_SNAPSHOT);
let lastStoreFilter: StoreFilter = "both";
const resetSessionState = (): void => { const resetSessionState = (): void => {
showCloseConfirm.value = false; showCloseConfirm.value = false;
@@ -127,16 +133,44 @@ export const createUpdateCenterStore = (): UpdateCenterStore => {
isBound = false; isBound = false;
}; };
const open = async (): Promise<void> => { const open = async (storeFilter: StoreFilter = "both"): Promise<void> => {
lastStoreFilter = storeFilter;
resetSessionState(); resetSessionState();
const nextSnapshot = await window.updateCenter.open();
applySnapshot(nextSnapshot);
isOpen.value = true; isOpen.value = true;
loading.value = true;
try {
const nextSnapshot = await window.updateCenter.open(storeFilter);
applySnapshot(nextSnapshot);
} finally {
loading.value = false;
}
}; };
const refresh = async (): Promise<void> => { const refresh = async (
const nextSnapshot = await window.updateCenter.refresh(); storeFilter: StoreFilter = lastStoreFilter,
applySnapshot(nextSnapshot); ): Promise<void> => {
lastStoreFilter = storeFilter;
loading.value = true;
try {
const nextSnapshot = await window.updateCenter.refresh(storeFilter);
applySnapshot(nextSnapshot);
} finally {
loading.value = false;
}
};
const ignoreItem = async (
packageName: string,
newVersion: string,
): Promise<void> => {
await window.updateCenter.ignore({ packageName, newVersion });
};
const unignoreItem = async (
packageName: string,
newVersion: string,
): Promise<void> => {
await window.updateCenter.unignore({ packageName, newVersion });
}; };
const toggleSelection = (taskKey: string): void => { const toggleSelection = (taskKey: string): void => {
@@ -175,6 +209,7 @@ export const createUpdateCenterStore = (): UpdateCenterStore => {
const closeNow = (): void => { const closeNow = (): void => {
resetSessionState(); resetSessionState();
loading.value = false;
isOpen.value = false; isOpen.value = false;
}; };
@@ -248,6 +283,7 @@ export const createUpdateCenterStore = (): UpdateCenterStore => {
return { return {
isOpen, isOpen,
loading,
showCloseConfirm, showCloseConfirm,
showMigrationConfirm, showMigrationConfirm,
searchQuery, searchQuery,
@@ -260,6 +296,8 @@ export const createUpdateCenterStore = (): UpdateCenterStore => {
unbind, unbind,
open, open,
refresh, refresh,
ignoreItem,
unignoreItem,
toggleSelection, toggleSelection,
toggleSelectAll, toggleSelectAll,
getSelectedItems, getSelectedItems,
+1 -1
View File
@@ -3,7 +3,7 @@ Dir::Cache::archives "/var/cache/apt/archives";
Dir::Cache "/var/lib/aptss/"; Dir::Cache "/var/lib/aptss/";
Dir::Etc::SourceParts "/opt/durapps/spark-store/bin/apt-fast-conf/sources.list.d/"; Dir::Etc::SourceParts "/opt/durapps/spark-store/bin/apt-fast-conf/sources.list.d/";
Dir::State::lists "/var/lib/aptss/lists/"; Dir::State::lists "/var/lib/aptss/lists/";
APT::Sandbox::User "root";
APT::Get::Fix-Broken true; APT::Get::Fix-Broken true;
APT::Get::List-Cleanup="0"; APT::Get::List-Cleanup="0";
+8 -22
View File
@@ -83,6 +83,9 @@ cleanup_aptfast()
if [ -n "$LISTTEMP" ] && [ -d "$LISTTEMP" ]; then if [ -n "$LISTTEMP" ] && [ -d "$LISTTEMP" ]; then
rm -rf "$LISTTEMP" rm -rf "$LISTTEMP"
fi fi
if [ -n "$tmpdir" ] && [ -d "$tmpdir" ]; then
rm -rf "$tmpdir"
fi
} }
exit_cleanup_state() exit_cleanup_state()
{ {
@@ -120,11 +123,8 @@ _create_lock()
# unlock and remove the lock file # unlock and remove the lock file
_remove_lock() _remove_lock()
{ {
# Only unlock if lock file exists (was created by _create_lock) flock -u "$LCK_FD" 2>/dev/null
if [ -f "$LCK_FILE.lock" ]; then rm -f "$LCK_FILE.lock"
flock -u "$LCK_FD" 2>/dev/null
rm -f "$LCK_FILE.lock"
fi
} }
# Search for known options and decide if root privileges are needed. # Search for known options and decide if root privileges are needed.
@@ -134,7 +134,6 @@ for argument in "$@"; do
case "$argument" in case "$argument" in
upgrade | full-upgrade | install | dist-upgrade | build-dep) upgrade | full-upgrade | install | dist-upgrade | build-dep)
option="install" option="install"
_create_lock
;; ;;
clean | autoclean) clean | autoclean)
option="clean" option="clean"
@@ -313,6 +312,9 @@ https_proxy=
[ "$TMP_http_proxy" = "$TMP_RANDOM" ] || http_proxy="$TMP_http_proxy" [ "$TMP_http_proxy" = "$TMP_RANDOM" ] || http_proxy="$TMP_http_proxy"
[ "$TMP_https_proxy" = "$TMP_RANDOM" ] || https_proxy="$TMP_https_proxy" [ "$TMP_https_proxy" = "$TMP_RANDOM" ] || https_proxy="$TMP_https_proxy"
if [ "$option" == "install" ]; then
_create_lock
fi
# Disable colors if not executed in terminal. # Disable colors if not executed in terminal.
if [ ! -t 1 ]; then if [ ! -t 1 ]; then
@@ -456,22 +458,12 @@ get_uris(){
exit "$CLEANUP_STATE" exit "$CLEANUP_STATE"
fi fi
prepare_auth prepare_auth
local tmpdir
tmpdir=$(mktemp -d) || { tmpdir=$(mktemp -d) || {
msg "Failed to create tmp dir" "warning" msg "Failed to create tmp dir" "warning"
msg "无法创建临时目录" "warning" msg "无法创建临时目录" "warning"
exit 1 exit 1
} }
cleanup_tmpdir() {
if [ -n "$tmpdir" ] && [ -d "$tmpdir" ]; then
rm -rf "$tmpdir"
fi
}
trap cleanup_tmpdir EXIT
## --print-uris format is: ## --print-uris format is:
# 'fileurl' filename filesize checksum_hint:filechecksum # 'fileurl' filename filesize checksum_hint:filechecksum
# 修改:process_package函数增加第二个参数表示当前线程的临时输出文件 # 修改:process_package函数增加第二个参数表示当前线程的临时输出文件
@@ -824,9 +816,6 @@ elif [ "$option" == "download" ]; then
"${_APTMGR}" "$@" "${_APTMGR}" "$@"
fi fi
# Clean up temporary directory for download command
cleanup_aptfast
elif [ "$option" == "source" ]; then elif [ "$option" == "source" ]; then
msg msg
msg "Working... this may take a while." "normal" msg "Working... this may take a while." "normal"
@@ -853,9 +842,6 @@ elif [ "$option" == "source" ]; then
# dpkg-source -x "$(basename "$srcfile")" # dpkg-source -x "$(basename "$srcfile")"
#done < "$DLLIST" #done < "$DLLIST"
# Clean up temporary directory for source command
cleanup_aptfast
# Execute package manager directly if unknown options are passed. # Execute package manager directly if unknown options are passed.
else else
"${_APTMGR}" "${APT_SCRIPT_WARNING[@]}" "$@" "${_APTMGR}" "${APT_SCRIPT_WARNING[@]}" "$@"
+220 -67
View File
@@ -5,10 +5,18 @@ load_transhell_debug
############################################################# #############################################################
function has-command() {
command -v "$1" >/dev/null 2>&1
}
# 发送通知 # 发送通知
function notify-send() { function notify-send() {
# Detect user using the display local user
local user=$(who | awk '{print $1}' | head -n 1) user=$(detect-notify-user)
if [ -z "$user" ]; then
return 1
fi
# Detect uid of the user # Detect uid of the user
local uid=$(id -u $user) local uid=$(id -u $user)
@@ -16,6 +24,96 @@ function notify-send() {
sudo -u $user DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/${uid}/bus notify-send "$@" sudo -u $user DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/${uid}/bus notify-send "$@"
} }
function detect-notify-user() {
local user
user=$(who | awk '{print $1}' | head -n 1)
if [ -n "$user" ]; then
echo "$user"
return 0
fi
if command -v loginctl >/dev/null 2>&1; then
user=$(loginctl list-sessions --no-legend 2>/dev/null | awk 'NR == 1 {print $3}')
if [ -n "$user" ]; then
echo "$user"
return 0
fi
fi
return 1
}
function load-ignored-apps() {
declare -gA ignored_apps=()
local config_paths=()
declare -A seen_config_paths=()
local user
local user_home
local config_path
user=$(detect-notify-user)
if [ -n "$user" ]; then
user_home=$(getent passwd "$user" | cut -d: -f6)
if [ -n "$user_home" ] && [ -d "$user_home" ]; then
config_path="$user_home/.config/spark-store/ignored_apps.conf"
if [ -f "$config_path" ] && [ -z "${seen_config_paths["$config_path"]}" ]; then
config_paths+=("$config_path")
seen_config_paths["$config_path"]=1
fi
fi
fi
local home_dir
for home_dir in /home/*; do
if [ ! -d "$home_dir" ]; then
continue
fi
config_path="$home_dir/.config/spark-store/ignored_apps.conf"
if [ -f "$config_path" ] && [ -z "${seen_config_paths["$config_path"]}" ]; then
config_paths+=("$config_path")
seen_config_paths["$config_path"]=1
fi
done
local pkg_name
local pkg_version
for config_path in "${config_paths[@]}"; do
while IFS='|' read -r pkg_name pkg_version || [ -n "$pkg_name" ]; do
pkg_name=$(printf '%s' "$pkg_name" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
pkg_version=$(printf '%s' "$pkg_version" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
if [ -n "$pkg_name" ] && [ -n "$pkg_version" ]; then
ignored_apps["$pkg_name|$pkg_version"]=1
fi
done < "$config_path"
done
}
function get-apm-upgradable-list() {
local output
output=$(env LANGUAGE=en_US apm list --upgradable 2>/dev/null | awk 'NR>1')
local ifs_old="$IFS"
IFS=$'\n'
local line
for line in $output; do
local pkg_name
local pkg_new_ver
local pkg_cur_ver
pkg_name=$(echo "$line" | awk -F '/' '{print $1}')
pkg_new_ver=$(echo "$line" | awk '{print $2}')
pkg_cur_ver=$(printf '%s\n' "$line" | sed -n 's/.*\[\(upgradable from\|from\):[[:space:]]*\([^]]*\)\].*/\2/p')
if [ -n "$pkg_name" ] && [ -n "$pkg_new_ver" ] && [ -n "$pkg_cur_ver" ]; then
echo "${pkg_name} ${pkg_new_ver} ${pkg_cur_ver}"
fi
done
IFS="$ifs_old"
}
# 检测网络链接畅通 # 检测网络链接畅通
function network-check() { function network-check() {
# 超时时间 # 超时时间
@@ -36,6 +134,21 @@ function network-check() {
fi fi
} }
has_aptss=0
has_apm=0
if has-command aptss; then
has_aptss=1
fi
if has-command apm; then
has_apm=1
fi
if [ "$has_aptss" -eq 0 ] && [ "$has_apm" -eq 0 ]; then
exit 0
fi
# 初始化等待时间和最大等待时间 # 初始化等待时间和最大等待时间
initial_wait_time=15 # 初始等待时间 15 秒 initial_wait_time=15 # 初始等待时间 15 秒
max_wait_time=$((12 * 3600)) # 最大等待时间 12 小时 max_wait_time=$((12 * 3600)) # 最大等待时间 12 小时
@@ -53,80 +166,120 @@ while ! network-check; do
fi fi
done done
# 每日更新星火源文件 load-ignored-apps
aptss update spark_update_count=0
if [ "$has_aptss" -eq 1 ]; then
# 每日更新星火源文件
aptss update
updatetext=`LANGUAGE=en_US aptss ssupdate 2>&1` updatetext=$(LANGUAGE=en_US aptss ssupdate 2>&1)
# 在网络恢复后,继续更新操作 # 在网络恢复后,继续更新操作
retry_count=0 retry_count=0
max_retries=12 # 最大重试次数,防止死循环 max_retries=12 # 最大重试次数,防止死循环
until ! echo $updatetext | grep -q "E:"; do until ! echo "$updatetext" | grep -q "E:"; do
if [ $retry_count -ge $max_retries ]; then if [ $retry_count -ge $max_retries ]; then
echo "Reached maximum retry limit for aptss ssupdate." echo "Reached maximum retry limit for aptss ssupdate."
exit 1 exit 1
fi
echo "${TRANSHELL_CONTENT_UPDATE_ERROR_AND_WAIT_15_SEC}"
sleep 15
updatetext=`LANGUAGE=en_US aptss ssupdate 2>&1`
retry_count=$((retry_count + 1))
done
update_app_number=$(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" 2>/dev/null | grep -c upgradable)
if [ "$update_app_number" -le 0 ]; then
exit 0
fi
# 读取忽略列表到数组
declare -A ignored_apps
if [ -f "/etc/spark-store/ignored_apps.conf" ]; then
while IFS='|' read -r pkg_name pkg_version || [ -n "$pkg_name" ]; do
# 去除前后空白字符
pkg_name=$(echo "$pkg_name" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
if [ -n "$pkg_name" ]; then
ignored_apps["$pkg_name"]=1
fi fi
done < "/etc/spark-store/ignored_apps.conf"
echo "${TRANSHELL_CONTENT_UPDATE_ERROR_AND_WAIT_15_SEC}"
sleep 15
updatetext=$(LANGUAGE=en_US aptss ssupdate 2>&1)
retry_count=$((retry_count + 1))
done
spark_update_count=$(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" 2>/dev/null | grep -c upgradable)
if [ "$spark_update_count" -gt 0 ]; then
# 获取用户选择的要更新的应用
PKG_LIST="$(/opt/durapps/spark-store/bin/update-upgrade/ss-do-upgrade-worker.sh upgradable-list)"
IFS_OLD="$IFS"
IFS=$'\n'
for line in $PKG_LIST; do
PKG_NAME=$(echo "$line" | awk -F ' ' '{print $1}')
PKG_NEW_VER=$(echo "$line" | awk -F ' ' '{print $2}')
PKG_CUR_VER=$(echo "$line" | awk -F ' ' '{print $3}')
dpkg --compare-versions "$PKG_NEW_VER" le "$PKG_CUR_VER"
if [ $? -eq 0 ]; then
spark_update_count=$((spark_update_count - 1))
continue
fi
PKG_STA=$(dpkg-query -W -f='${db:Status-Want}' "$PKG_NAME")
if [ "$PKG_STA" = "hold" ]; then
spark_update_count=$((spark_update_count - 1))
continue
fi
if [ -n "${ignored_apps["$PKG_NAME|$PKG_NEW_VER"]}" ]; then
spark_update_count=$((spark_update_count - 1))
continue
fi
done
IFS="$IFS_OLD"
fi
fi fi
# 获取用户选择的要更新的应用 apm_update_count=0
PKG_LIST="$(/opt/durapps/spark-store/bin/update-upgrade/ss-do-upgrade-worker.sh upgradable-list)" if [ "$has_apm" -eq 1 ]; then
# 指定分隔符为 \n updatetext=$(LANGUAGE=en_US apm update 2>&1)
IFS_OLD="$IFS" retry_count=0
IFS=$'\n' max_retries=12
for line in $PKG_LIST; do until ! echo "$updatetext" | grep -q "E:"; do
PKG_NAME=$(echo $line | awk -F ' ' '{print $1}') if [ $retry_count -ge $max_retries ]; then
PKG_NEW_VER=$(echo $line | awk -F ' ' '{print $2}') echo "Reached maximum retry limit for apm update."
PKG_CUR_VER=$(echo $line | awk -F ' ' '{print $3}') exit 1
fi
dpkg --compare-versions $PKG_NEW_VER le $PKG_CUR_VER echo "Update failed...Will retry in 15sec"
sleep 15
updatetext=$(LANGUAGE=en_US apm update 2>&1)
retry_count=$((retry_count + 1))
done
if [ $? -eq 0 ]; then apm clean
let update_app_number=$update_app_number-1 PKG_LIST="$(get-apm-upgradable-list)"
continue apm_update_count=$(printf '%s\n' "$PKG_LIST" | awk 'NF { count++ } END { print count + 0 }')
if [ "$apm_update_count" -gt 0 ]; then
IFS_OLD="$IFS"
IFS=$'\n'
for line in $PKG_LIST; do
PKG_NAME=$(echo "$line" | awk -F ' ' '{print $1}')
PKG_NEW_VER=$(echo "$line" | awk -F ' ' '{print $2}')
PKG_CUR_VER=$(echo "$line" | awk -F ' ' '{print $3}')
amber-pm-debug dpkg --compare-versions "$PKG_NEW_VER" le "$PKG_CUR_VER"
if [ $? -eq 0 ]; then
apm_update_count=$((apm_update_count - 1))
continue
fi
PKG_STA=$(amber-pm-debug dpkg-query -W -f='${db:Status-Want}' "$PKG_NAME")
if [ "$PKG_STA" = "hold" ]; then
apm_update_count=$((apm_update_count - 1))
continue
fi
if [ -n "${ignored_apps["$PKG_NAME|$PKG_NEW_VER"]}" ]; then
apm_update_count=$((apm_update_count - 1))
continue
fi
done
IFS="$IFS_OLD"
fi fi
fi
# 检测是否是 hold 状态 update_app_number=$((spark_update_count + apm_update_count))
PKG_STA=$(dpkg-query -W -f='${db:Status-Want}' $PKG_NAME) if [ "$update_app_number" -le 0 ]; then
if [ "$PKG_STA" = "hold" ]; then
let update_app_number=$update_app_number-1
continue
fi
# 检测是否在忽略列表中
if [ -n "${ignored_apps[$PKG_NAME]}" ]; then
let update_app_number=$update_app_number-1
continue
fi
done
# 还原分隔符
IFS="$IFS_OLD"
if [ $update_app_number -le 0 ]; then
exit 0 exit 0
fi fi
update_transhell update_transhell
@@ -135,8 +288,8 @@ update_transhell
# TODO: 除了apt-mark hold之外额外有一个禁止检查列表 # TODO: 除了apt-mark hold之外额外有一个禁止检查列表
# 如果不想提示就不提示 # 如果不想提示就不提示
user=$(who | awk '{print $1}' | head -n 1) user=$(detect-notify-user)
if [ -e "/home/$user/.config/spark-union/spark-store/ssshell-config-do-not-show-upgrade-notify" ]; then if [ -n "$user" ] && [ -e "/home/$user/.config/spark-union/spark-store/ssshell-config-do-not-show-upgrade-notify" ]; then
echo "他不想站在世界之巅,好吧" echo "他不想站在世界之巅,好吧"
echo "Okay he don't want to be at the top of the world, okay" echo "Okay he don't want to be at the top of the world, okay"
exit exit