- Changed `shell: true` to `shell: false` in `spawn` calls within `electron/main/backend/install-manager.ts`. - Updated `AGENTS.md` documentation to reflect the security best practice. - Verified that the fix prevents command injection using a reproduction script. Co-authored-by: vmomenv <51269338+vmomenv@users.noreply.github.com>
22 KiB
APM 应用商店 - AI 编码指南
仓库: elysia-best/apm-app-store 项目类型: Electron + Vue 3 + Vite 桌面应用 用途: APM (AmberPM) 包管理器的桌面应用商店客户端 许可证: GPL-3.0
如果你是 AI 编码代理,在此仓库工作时,请遵循以下指南:
🏗️ 项目架构概览
技术栈
- 前端框架: Vue 3 with Composition API (
<script setup>) - 构建工具: Vite 6.4.1
- 桌面框架: Electron 40.0.0
- UI 框架: Tailwind CSS 4.1.18
- 语言: TypeScript (严格模式已启用)
- 状态管理: Vue 响应式系统 (ref, computed)
- HTTP 客户端: Axios 1.13.2
- 日志: Pino logger
目录结构
apm-app-store/
├── electron/ # Electron 主进程
│ ├── main/
│ │ ├── backend/ # 后端逻辑 (如安装管理器)
│ │ │ └── install-manager.ts # 核心安装/包管理
│ │ ├── deeplink.ts # Deep Link 协议处理
│ │ ├── handle-url-scheme.ts # URL Scheme 处理器
│ │ └── index.ts # 主进程入口点
│ ├── preload/
│ │ └── index.ts # 预加载脚本 (IPC 桥梁)
│ └── global.ts # 进程间共享状态
├── src/ # Vue 渲染进程
│ ├── components/ # Vue 组件 (模态框、卡片、网格)
│ ├── global/ # 全局配置和状态
│ │ ├── downloadStatus.ts # 下载队列管理
│ │ ├── storeConfig.ts # API 配置和共享状态
│ │ └── typedefinition.ts # TypeScript 类型定义
│ ├── modules/ # 业务逻辑模块
│ │ └── processInstall.ts # 安装/卸载逻辑
│ ├── assets/ # CSS/图片
│ ├── App.vue # 根组件
│ └── main.ts # 渲染进程入口
├── extras/ # Shell 脚本和策略文件
├── icons/ # 应用图标
├── scripts/ # 维护脚本
├── public/ # 公共资源
├── electron-builder.yml # 构建配置
├── vite.config.ts # Vite 配置
├── tsconfig.json # TypeScript 配置
├── eslint.config.ts # ESLint 配置
└── package.json # 依赖和脚本
🎯 核心概念
1. APM 包管理器集成
本应用作为 APM CLI 工具 (/opt/spark-store/extras/shell-caller.sh) 的 GUI 前端。
关键操作:
apm install -y <pkgname>- 安装包apm remove -y <pkgname>- 卸载包apm list --installed- 列出已安装的包apm list --upgradable- 列出可升级的包
重要: 在 Linux 上,所有 APM 操作都需要通过 pkexec 进行权限提升。
2. IPC 通信模式
主进程 (Node.js) ⟷ 渲染进程 (浏览器)
// 在渲染进程中 (Vue):
window.ipcRenderer.send('queue-install', JSON.stringify(download));
window.ipcRenderer.invoke('list-installed');
window.ipcRenderer.on('install-complete', (event, result) => { /* ... */ });
// 在主进程中 (Electron):
ipcMain.on('queue-install', async (event, download_json) => { /* ... */ });
ipcMain.handle('list-installed', async () => { /* ... */ });
event.sender.send('install-complete', { id, success, ... });
在 Preload 中暴露的 API:
window.ipcRenderer.on/off/send/invoke- IPC 通信window.apm_store.arch- 系统架构检测 (amd64-store, arm64-store)
3. 安装队列系统
位置: electron/main/backend/install-manager.ts
关键特性:
- 单任务顺序处理(一次只能安装一个应用)
- 通过
Map<number, InstallTask>管理任务队列 - 通过 IPC 事件实时流式传输进度
- 自动检测权限提升
任务生命周期:
- 渲染进程发送
queue-install,包含任务 ID 和包名 - 主进程检查是否有重复任务
- 任务加入队列,状态为
queued processNextInQueue()生成子进程- stdout/stderr 通过
install-log流式传输到渲染进程 - 通过
install-complete发送完成信号和退出码
关键模式:
// 在处理前始终检查是否空闲
if (idle) processNextInQueue(0);
// 任务完成后,检查是否有更多任务
tasks.delete(task.id);
idle = true;
if (tasks.size > 0) processNextInQueue(0);
📝 类型系统指南
重要: 不要在代码中使用 any!
核心类型 (src/global/typedefinition.ts)
应用数据结构
// 从 API 返回的原始 JSON (PascalCase)
interface AppJson {
Name: string;
Pkgname: string;
Version: string;
Filename: string;
// ... (遵循上游 API 格式)
}
// 标准化的应用数据 (camelCase)
interface App {
name: string;
pkgname: string; // 主要标识符
version: string;
category: string; // 由前端添加
currentStatus: "not-installed" | "installed";
flags?: string; // 例如: "automatic" 表示依赖
arch?: string; // 例如: "amd64", "arm64"
// ... (完整定义见 typedefinition.ts)
}
下载/安装任务
interface DownloadItem {
id: number; // 唯一任务 ID
pkgname: string; // 包标识符
status: DownloadItemStatus; // "queued" | "installing" | "completed" | "failed" | "paused"
progress: number; // 0-100
logs: Array<{ time: number; message: string }>;
retry: boolean; // 重试标志
upgradeOnly?: boolean; // 用于升级操作
// ... (完整定义)
}
IPC 负载
interface InstallLog {
id: number;
time: number;
message: string;
}
interface DownloadResult extends InstallLog {
success: boolean;
exitCode: number | null;
}
🎨 Vue 组件模式
Composition API 最佳实践
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import type { Ref } from 'vue';
// 响应式状态
const apps: Ref<App[]> = ref([]);
const loading = ref(true);
// 计算属性
const filteredApps = computed(() => {
return apps.value.filter(/* ... */);
});
// 生命周期钩子
onMounted(async () => {
await loadCategories();
await loadApps();
});
// 方法 (使用箭头函数或函数声明)
const handleInstall = () => {
// 实现
};
</script>
Props 和 Events 模式
// Props 定义
const props = defineProps<{
app: App | null;
show: boolean;
}>();
// Emits 定义
const emit = defineEmits<{
close: [];
install: [];
remove: [];
}>();
// 使用
emit("install");
Vue 中的 IPC 事件监听
始终在 onMounted 中使用以便正确清理:
onMounted(() => {
window.ipcRenderer.on(
"install-complete",
(_event: IpcRendererEvent, result: DownloadResult) => {
// 处理事件
},
);
window.ipcRenderer.on(
"install-log",
(_event: IpcRendererEvent, log: InstallLog) => {
// 处理日志
},
);
});
🔧 主进程模式
生成 APM 命令
import { spawn } from "node:child_process";
// 检查权限提升
const superUserCmd = await checkSuperUserCommand();
const execCommand = superUserCmd.length > 0 ? superUserCmd : SHELL_CALLER_PATH;
const execParams =
superUserCmd.length > 0
? [SHELL_CALLER_PATH, "apm", "install", "-y", pkgname]
: ["apm", "install", "-y", pkgname];
// 生成进程
const child = spawn(execCommand, execParams, {
shell: false,
env: process.env,
});
// 流式输出
child.stdout.on("data", (data) => {
webContents.send("install-log", {
id,
time: Date.now(),
message: data.toString(),
});
});
// 处理完成
child.on("close", (code) => {
const success = code === 0;
webContents.send("install-complete", {
id,
success,
exitCode: code /* ... */,
});
});
解析 APM 输出
APM 输出是基于文本的特定格式:
// 已安装包格式: "pkgname/repo,version arch [flags]"
// 示例: "code/stable,1.108.2 amd64 [installed]"
const parseInstalledList = (output: string) => {
const apps: InstalledAppInfo[] = [];
const lines = output.split("\n");
for (const line of lines) {
const match = line
.trim()
.match(/^(\S+)\/\S+,\S+\s+(\S+)\s+(\S+)\s+\[(.+)\]$/);
if (match) {
apps.push({
pkgname: match[1],
version: match[2],
arch: match[3],
flags: match[4],
raw: line.trim(),
});
}
}
return apps;
};
🌐 API 集成
基础配置
// src/global/storeConfig.ts
export const APM_STORE_BASE_URL = 'https://erotica.spark-app.store';
// URL 结构:
// /{arch}/{category}/applist.json - 应用列表
// /{arch}/{category}/{pkgname}/icon.png - 应用图标
// /{arch}/{category}/{pkgname}/screen_N.png - 截图 (1-5)
// /{arch}/categories.json - 分类映射
### 首页 (主页) 数据
商店可能会在 `{arch}` 下提供一个特殊的 `home` 目录用于本地化首页。预期有两个 JSON 文件:
- `homelinks.json` — 用于构建首页的轮播或链接块。每个条目示例:
```json
{
"name": "交流反馈",
"more": "前往论坛交流讨论",
"imgUrl": "/home/links/bbs.png",
"type": "_blank",
"url": "https://bbs.spark-app.store/"
}
homelist.json— 描述若干推荐应用列表,每项引用一个 JSON 列表(jsonUrl):
[
{
"name": "装机必备",
"type": "appList",
"jsonUrl": "/home/lists/NecessaryforInstallation.json"
}
]
应用使用的解析规则:
- 通过前缀解析
imgUrl:${APM_STORE_BASE_URL}/{arch}${imgUrl}。 type: _blank→ 使用系统浏览器打开链接;type: _self→ 在当前页面打开。- 对于
homelist.json中type: "appList"的条目,获取引用的jsonUrl并将每个项映射到 UI 使用的应用形状:Name→namePkgname→pkgnameCategory→categoryMore→more
实现位置:
- 渲染进程:
src/App.vue在选择home分类时加载并规范化homelinks.json和homelist.json,并将数据暴露给新的HomeView组件。 - 组件:
src/components/HomeView.vue渲染链接卡片和推荐应用部分(复用AppCard.vue)。
注意事项:
home目录路径是: 配置的APM_STORE_BASE_URL下的/{arch}/home/。- 缺失或部分无效的文件会被优雅地处理 — 个别失败不会阻止显示其他首页部分。
### Axios 使用
```typescript
const axiosInstance = axios.create({
baseURL: APM_STORE_BASE_URL,
timeout: 1000, // 注意: 非常短的超时时间!
});
// 按分类加载应用
const response = await axiosInstance.get<AppJson[]>(
`/${window.apm_store.arch}/${category}/applist.json`
);
开发代理 (vite.config.ts):
server: {
proxy: {
'/local_amd64-store': {
target: 'https://erotica.spark-app.store',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/local_amd64-store/, ''),
}
}
}
🎯 Deep Link 协议 (SPK URI Scheme)
URL Scheme: spk://
支持的 SPK URI 格式
格式: spk://search/{pkgname}
示例:
spk://search/code- 搜索并打开 "code" 应用spk://search/steam- 搜索并打开 "steam" 应用spk://search/store.spark-app.hmcl- 搜索并打开 "HMCL" 游戏
实现模式
// electron/main/deeplink.ts - 解析命令行并路由
export function handleCommandLine(commandLine: string[]) {
const deeplinkUrl = commandLine.find((arg) => arg.startsWith("spk://"));
if (!deeplinkUrl) return;
try {
const url = new URL(deeplinkUrl);
const action = url.hostname; // 'search'
if (action === "search") {
// 格式: spk://search/pkgname
// url.pathname 将是 '/pkgname'
const pkgname = url.pathname.split("/").filter(Boolean)[0];
if (pkgname) {
listeners.emit("search", { pkgname });
}
}
} catch (error) {
logger.error({ err: error }, "Error parsing SPK URI");
}
}
// src/App.vue - 在渲染进程中处理
window.ipcRenderer.on(
"deep-link-search",
(_event: IpcRendererEvent, data: { pkgname: string }) => {
// 使用 pkgname 触发搜索
searchQuery.value = data.pkgname;
},
);
🛡️ 安全考虑
权限提升
始终检查 pkexec 的可用性:
const checkSuperUserCommand = async (): Promise<string> => {
if (process.getuid && process.getuid() !== 0) {
const { stdout } = await execAsync("which /usr/bin/pkexec");
return stdout.trim().length > 0 ? "/usr/bin/pkexec" : "";
}
return "";
};
上下文隔离
当前状态: 上下文隔离已 启用 (Electron 默认行为)。
通过 Preload 安全地暴露 IPC:
// electron/preload/index.ts
contextBridge.exposeInMainWorld("ipcRenderer", {
on: (...args) => ipcRenderer.on(...args),
send: (...args) => ipcRenderer.send(...args),
invoke: (...args) => ipcRenderer.invoke(...args),
});
⚠️ 不要启用 nodeIntegration 或禁用 contextIsolation!
🎨 UI/UX 模式
Tailwind CSS 使用
暗色模式支持:
<div class="bg-slate-50 dark:bg-slate-950 text-slate-900 dark:text-slate-100">
<!-- 内容 -->
</div>
主题切换:
const isDarkTheme = ref(false);
watch(isDarkTheme, (newVal) => {
localStorage.setItem("theme", newVal ? "dark" : "light");
document.documentElement.classList.toggle("dark", newVal);
});
模态框模式
<template>
<div v-if="show" class="fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/50" @click="closeModal"></div>
<div class="modal-panel relative z-10 bg-white dark:bg-slate-900">
<!-- 模态框内容 -->
</div>
</div>
</template>
加载状态
const loading = ref(true);
// 在模板中
<div v-if="loading">Loading...</div>
<div v-else>{{ apps.length }} apps</div>
🧪 测试与质量
ESLint 配置
// eslint.config.ts
export default defineConfig([
globalIgnores([
"**/3rdparty/**",
"**/node_modules/**",
"**/dist/**",
"**/dist-electron/**",
]),
tseslint.configs.recommended,
pluginVue.configs["flat/essential"],
eslintConfigPrettier,
eslintPluginPrettierRecommended,
]);
TypeScript 配置
{
"compilerOptions": {
"strict": true, // 严格模式已启用
"noEmit": true, // 不输出 (Vite 处理构建)
"module": "ESNext",
"target": "ESNext",
"jsx": "preserve", // Vue JSX
"resolveJsonModule": true // 导入 JSON 文件
}
}
代码质量命令
npm run lint # 运行 ESLint
npm run lint:fix # 自动修复问题
npm run format # 使用 Prettier 格式化
🚀 构建与开发
开发模式
npm run dev # 启动开发服务器 (Vite + Electron)
开发服务器: http://127.0.0.1:3344/ (来自 package.json)
生产构建
npm run build # 构建所有 (deb + rpm)
npm run build:deb # 仅构建 Debian 包
npm run build:rpm # 仅构建 RPM 包
构建输出:
dist-electron/- 编译的 Electron 代码dist/- 编译的渲染器资源- 打包的应用在项目根目录
构建配置
electron-builder.yml:
- App ID:
cn.eu.org.simplelinux.apmstore - Linux 目标: deb, rpm
- 包含 extras/ 目录在资源中
- 自动更新禁用 (Linux 包管理器处理更新)
📦 重要文件说明
1. electron/main/backend/install-manager.ts
用途: 核心包管理逻辑 主要职责:
- 任务队列管理
- APM 命令生成
- 进度报告
- 已安装/可升级列表解析
关键函数:
processNextInQueue()- 任务处理器parseInstalledList()- 解析 APM 输出checkSuperUserCommand()- 权限提升
2. src/App.vue
用途: 根组件 主要职责:
- 应用状态管理
- 分类/应用加载
- 模态框协调
- Deep Link 处理
3. src/global/downloadStatus.ts
用途: 下载队列状态 关键特性:
- 响应式下载列表
- 下载项 CRUD 操作
- UI 更新的变化监听器
4. electron/preload/index.ts
用途: 渲染进程-主进程桥梁 关键特性:
- IPC API 暴露
- 架构检测
- 加载动画
5. vite.config.ts
用途: 构建配置 关键特性:
- Electron 插件设置
- 开发服务器代理
- Tailwind 集成
🐛 常见陷阱与解决方案
1. 重复任务处理
问题: 用户多次点击安装 解决方案:
if (tasks.has(id) && !download.retry) {
logger.warn("Task already exists, ignoring duplicate");
return;
}
2. 窗口关闭行为
问题: 任务运行时关闭窗口 解决方案:
win.on("close", (event) => {
event.preventDefault();
if (tasks.size > 0) {
win.hide(); // 隐藏而不是关闭
win.setSkipTaskbar(true);
} else {
win.destroy(); // 没有任务时允许关闭
}
});
3. 应用数据规范化
问题: API 返回 PascalCase,应用使用 camelCase 解决方案:
const normalizedApp: App = {
name: appJson.Name,
pkgname: appJson.Pkgname,
version: appJson.Version,
// ... 映射所有字段
};
4. 截图加载
问题: 并非所有应用都有 5 张截图 解决方案:
for (let i = 1; i <= 5; i++) {
const img = new Image();
img.src = screenshotUrl;
img.onload = () => screenshots.value.push(screenshotUrl);
// 没有 onerror 处理器 - 静默跳过缺失的图片
}
📚 日志最佳实践
Pino Logger 使用
import pino from "pino";
const logger = pino({ name: "module-name" });
// 级别: trace, debug, info, warn, error, fatal
logger.info("Application started");
logger.error({ err }, "Failed to load apps");
logger.warn(`Package ${pkgname} not found`);
日志位置
开发: 控制台使用 pino-pretty
生产: 结构化 JSON 到 stdout
🔄 状态管理
全局状态 (src/global/storeConfig.ts)
export const currentApp = ref<App | null>(null);
export const currentAppIsInstalled = ref(false);
使用模式:
import { currentApp, currentAppIsInstalled } from "@/global/storeConfig";
// 设置当前应用
currentApp.value = selectedApp;
// 检查安装状态
window.ipcRenderer
.invoke("check-installed", app.pkgname)
.then((isInstalled: boolean) => {
currentAppIsInstalled.value = isInstalled;
});
下载队列 (src/global/downloadStatus.ts)
export const downloads = ref<DownloadItem[]>([]);
// 添加下载
downloads.value.push(newDownload);
// 移除下载
export const removeDownloadItem = (pkgname: string) => {
const index = downloads.value.findIndex((d) => d.pkgname === pkgname);
if (index !== -1) downloads.value.splice(index, 1);
};
// 监听变化
export const watchDownloadsChange = (callback: () => void) => {
watch(downloads, callback, { deep: true });
};
🎯 贡献指南
添加新功能时
- 首先添加 TypeScript 类型 (src/global/typedefinition.ts)
- 更新 IPC 处理器 (如果需要主进程-渲染进程通信)
- 遵循现有组件模式 (props, emits, setup)
- 使用实际的 APM 命令测试 (不要在开发中使用 mock)
- 完成任务时更新 README TODO 列表
代码风格
- 使用 TypeScript 严格模式 - 没有
any类型,除非使用eslint-disable - 避免直接使用 eslint-disable - 如果你真的不知道类型,使用
undefined代替 - 优先使用 Composition API -
<script setup lang="ts"> - 在 setup 中使用箭头函数 作为方法
- 解构导入 -
import { ref } from 'vue' - 遵循命名约定:
- 组件: PascalCase (AppCard.vue)
- 函数: camelCase (handleInstall)
- 常量: UPPER_SNAKE_CASE (SHELL_CALLER_PATH)
提交信息格式
type(scope): subject
示例:
feat(install): add retry mechanism for failed installations
fix(ui): correct dark mode toggle persistence
refactor(ipc): simplify install manager event handling
docs(readme): update build instructions
建议添加签名到提交。
🔗 相关资源
- APM 项目: https://gitee.com/spark-store-project/AmberPM
- Electron 文档: https://www.electronjs.org/docs
- Vue 3 文档: https://vuejs.org/
- Vite 文档: https://vitejs.dev/
- Tailwind CSS: https://tailwindcss.com/
⚠️ 已知问题和 TODO
详见 README.md。
🎓 新贡献者学习路径
阶段 1: 理解技术栈
- 阅读 Vue 3 Composition API 文档
- 查看 Electron IPC 通信模式
- 理解 APM 包管理器基础
阶段 2: 探索代码
- 从
src/App.vue开始 - 查看应用状态如何流动 - 学习
electron/main/backend/install-manager.ts- 理解任务队列 - 查看
src/global/typedefinition.ts- 学习数据结构
阶段 3: 做第一次修改
- 从 TODO 列表中选择一项 (优先 UI 改进)
- 创建功能分支
- 遵循代码风格指南
- 使用实际的 APM 命令测试
- 提交清晰的 PR 描述
🤖 AI Agent 特定指令
生成代码时
- 始终先检查现有模式 - 搜索类似的实现
- 保持类型安全 - 没有隐式 any,使用显式类型
- 遵循 IPC 命名约定:
- 主进程 → 渲染进程:
kebab-case(例如:install-complete) - 处理器: 描述性名称 (例如:
queue-install,list-installed)
- 主进程 → 渲染进程:
- 错误处理:
- 始终捕获 promises
- 记录带有上下文的错误
- 发送用户友好的消息到渲染进程
- 状态更新:
- 使用 Vue 响应式 (ref, reactive)
- 避免直接数组变异,使用展开运算符
- 在异步操作完成前更新 UI
修复 Bug 时
- 首先复现 - 理解上下文
- 检查 IPC 通信 - 许多 Bug 是时序问题
- 验证 APM 输出解析 - 格式可能会改变
- 测试边界情况:
- 缺失的包
- 网络失败
- 权限提升失败
- 快速按钮点击
重构时
- 不要破坏 IPC 契约 - 保持事件名称和负载兼容
- 保持类型安全 - 在代码之前更新类型
- 端到端测试安装流程
- 更新注释和文档
必须执行
- Lint 代码
- 格式化代码
- 构建 vite 项目以检查错误
文档版本: 1.0 最后更新: 2026-03-10 生成对象: 在 elysia-best/apm-app-store 工作的 AI 编码代理
本文档应随着代码库的演变而更新。如有疑问,请参考实际源代码,并优先考虑类型安全和用户体验。