Compare commits

...
245 Commits
Author SHA1 Message Date
shenmo7192andGitee 018dac3d21 !418 fix(store): 修复应用截图加载逻辑
Merge pull request !418 from xiyidaiwa/Erotica
2026-08-18 08:26:40 +00:00
xiyidaiwa f94a6584a4 fix(store): 修复应用截图加载逻辑
- 截图候选 URL 改为按固定路径拼接 screen_1~5.png,不再依赖后端 img_urls
- 打开详情页时先清空截图,避免残留上一个应用的图片
- 更新生产环境 APM_STORE_BASE_URL 域名
- 修正 debian/changelog 版本号
2026-08-18 15:04:08 +08:00
shenmo7192andGitee 25bc414ce1 !417 style(titleBar): 搜索框细节对齐
Merge pull request !417 from zinface/styles/window-title-bar-search
2026-08-18 05:50:37 +00:00
shenmo7192andGitee 0c18d53db6 !416 (titleBar): 使用 Font Awesome 图标集替代字符形式窗口控制标识, 且状态由根组件维护
Merge pull request !416 from zinface/styles/window-title-bar
2026-08-18 05:50:21 +00:00
shenmo7192andGitee 55247a72da !414 feat: 新增字体大小设置与界面整体缩放,修复放大后设置页溢出
Merge pull request !414 from xiyidaiwa/Erotica
2026-08-18 05:49:52 +00:00
shenmo7192andGitee 791b196256 !415 fix(systemd): 修复服务配置单元(unit) 的语法问题
Merge pull request !415 from zinface/systemd/service-unit-fix
2026-08-18 05:49:28 +00:00
shenmo7192andGitee eb4f01da94 !413 fix: 使用定时器而非服务来执行周期性更新检查
Merge pull request !413 from zinface/systemd/spark-update
2026-08-18 05:47:12 +00:00
zinface 0ea075c599 style(titleBar): 搜索框细节对齐 2026-08-18 11:51:13 +08:00
zinface fdc8455640 feat(titleBar): 根组件维护并传入 isMaximized 状态 2026-08-18 11:40:01 +08:00
zinface f2aa1573fa electron(titleBar): 添加窗口状态IPC通信与桥接器函数 2026-08-18 11:36:43 +08:00
zinface 08038e6be0 style(titleBar): 使用 Font Awesome 图标集替代字符形式窗口控制标识 2026-08-18 11:35:07 +08:00
zinface bc49a41018 fix(systemd): 修复服务配置单元(unit) 的语法问题 2026-08-18 10:21:08 +08:00
zinface b87112fa38 fix(systemd): 分析 timer 无效原因及解决方案
我看了下,这 timer 无效有几个原因
1. timer 本身要 enable 和 start
2. service 里要去掉 RemainAfterExit=yes 这条(不能让服务一直处于 activate)
3. timer 重新设计更新触发时间(6,12,18:00) 一天更新3次,分布到 30min 内
4. service 重新保持开机运行一次(撤回提交中的部分修改)

起码确定这不是服务器,不再 6h 分布,而是桌面系统,及时在到达时间后,在30min内完成更新(也同样不拥挤在一块),话说回来,到点后,在30分钟内统一更新所有安装了星火应用商店的机器,这用户量应该能支撑得住噻?
2026-08-18 09:14:00 +08:00
xiyidaiwa d0e6dc1b79 feat(build): 更新测试构建脚本以同步应用内版本号
- 为 electron-builder.yml 添加 extraMetadata.version 临时配置
- 实现打包完成后自动恢复 electron-builder.yml 文件
- 添加 trap 机制确保异常退出时也能正确恢复配置
- 清理旧的 electron-builder 产物避免内容污染
- 完善脚本注释说明版本同步机制和使用注意事项
2026-08-18 09:09:52 +08:00
xiyidaiwa 81567f8351 refactor(display): 移除字体大小设置,仅保留界面整体缩放
用户实测发现仅调字体到特大/超大时主页面卡片布局遮挡,且字体大小
与界面缩放功能效果高度重叠。故采用方案1:删除字体大小入口,放大
统一由 Electron setZoomFactor 处理(整体等比缩放,不破坏布局)。

- displaySettings.ts:移除 FontSizeOption/FONT_SIZE_OPTIONS/applyFontSize/
  setFontSize/initFontSize 等全部字体相关导出,仅保留界面缩放
- App.vue:移除 initFontSize 导入与调用
- SettingsModal.vue:删除「字体大小」卡片及所有相关逻辑,仅留「界面缩放」
- 还原 8 个组件中当初为字体联动改写的 rem 为原始 px(10/11/12/13/15px),
  消除对 html font-size 的依赖,卡片布局不再因字号变化溢出
2026-08-18 00:15:24 +08:00
xiyidaiwa ede1a1269f fix:更新版本号到5.3.0.2 2026-08-17 23:46:15 +08:00
xiyidaiwa e25a3c9d8f feat: 新增界面整体缩放与字体超大档,修复设置页溢出
- 界面缩放:Electron webContents.setZoomFactor,档位 90%/100%/110%/125%/150%
  - 主进程新增 set-zoom-factor IPC,启动时从 window-state.json 恢复 zoomFactor
  - 窗口尺寸保存与退出前均持久化当前 zoom
  - displaySettings 新增 UiScaleOption + initUiScale,App.vue 启动恢复
  - SettingsModal 新增「界面缩放」卡片,实时经 IPC 应用并持久化
- 字体大小新增「超大」档(html font-size 20px),共 5 档
- 修复设置模态框在字体/界面放大后内容超出视口:面板限制
  max-h-[calc(100vh-2rem)],内容区 flex-1 overflow-y-auto 滚动
2026-08-17 23:40:23 +08:00
xiyidaiwa 7b658e9dd1 fix(ui): 将固定 px 字号改为 rem,让全局字体大小档位生效
用户反馈首页红框区域(横幅标题/副标题)没随字号档位变大。
原因是多处使用了 Tailwind 固定 px 任意值(text-[10px]/[11px]/[12px]/[13px]/[15px]),
它们不随 html font-size 缩放。统一替换为等效 rem 任意值:
  10px → 0.625rem, 11px → 0.6875rem, 12px → 0.75rem, 13px → 0.8125rem, 15px → 0.9375rem
涉及 8 个 Vue 组件,保证字号档位全局联动(仍保持 16px 基准下的原视觉大小)。
vue-tsc 通过,打包成功;HomeView.vue 原有 emit 未使用 lint 错误未动。
2026-08-17 23:15:18 +08:00
xiyidaiwa 4dc1a1d7fe feat(settings): 新增字体大小设置(仅字号档位,不含整体缩放/字体族)
- 新增 src/global/displaySettings.ts:small/medium/large/xlarge 四档 → html 15/16/17/18px,
  localStorage(spark-store-font-size) 持久化,applyFontSize 写 html font-size 全局联动 rem 字号类
- App.vue onMounted 最前 initFontSize()(与 initTagPriorityStrategy 同级,防字号闪烁)
- SettingsModal.vue 新增「字体大小」卡片(4 档分段按钮 + 预览),selectFontSize 即时应用+持久化
- 用户确认范围:仅字号档位,整体缩放与字体族切换本期不做
- vue-tsc/lint/打包均通过
2026-08-17 23:00:14 +08:00
shenmo7192 2f85f1da71 fix(ui): 首页欢迎标题字号与左侧栏一致 2026-08-17 22:58:14 +08:00
shenmo7192 700197bdc1 fix(ui): 将首页推荐文字调大至与左侧栏一致 2026-08-17 22:45:03 +08:00
zinface 582435b857 fix: 使用定时器而非服务来执行周期性更新检查 2026-08-17 21:53:11 +08:00
shenmo7192andGitee e4540252b3 !412 fix(install-manager): 按 dpkg 规则放宽 deb 文件名校验,修复含 epoch 冒号包被误拦
Merge pull request !412 from xiyidaiwa/Erotica
2026-08-17 02:30:47 +00:00
xiyidaiwa 5efed0dca2 修改版本号为5.3.0.1 2026-08-17 09:15:29 +08:00
xiyidaiwa ada3a6dab4 fix(install-manager): 按 dpkg 规则放宽 deb 文件名校验,修复含 epoch 冒号包被误拦
- 新增 FILENAME_PATTERN,按 Debian 规则允许包名/版本号合法字符:含 epoch 冒号
  (如 steam-launcher_1:1.0.0.87_amd64.deb)与排序标记 ~(如 1.0~beta),及字母数字 . _ + -
- filename 校验由严格 PKGNAME_PATTERN 改为 FILENAME_PATTERN,并保留路径遍历双保险
  (拒绝 '..' 段 + path.basename 拒绝含 '/' 的路径),安全性未削弱
- 修正字符组内 [:~] 正则区间陷阱:用 \- 转义隔离,避免误匹配 <,=,>,?,@,[,] 等符号
- 已用真实仓库 2085 个包验证:包名/文件名拒绝均为 0,全部可正常放行下载与安装
2026-08-17 09:12:08 +08:00
shenmo7192 ae9142ad06 update 5.3.0 2026-08-15 16:53:11 +08:00
shenmo7192 b916fc90f3 bump ver: 5.3.0 2026-08-15 16:46:39 +08:00
shenmo7192 f3728eae00 feat(detail): 应用详情默认展开已安装版本 2026-08-15 16:46:32 +08:00
shenmo7192 b4fbd44ed9 bump ver 2026-08-15 16:14:28 +08:00
shenmo7192andGitee a4d75c01ad !410 bump(debian/changelog): 5.3.0-0
Merge pull request !410 from AAA Elysia 猫猫侠 ⁧~喵/N/A
2026-08-14 15:15:24 +00:00
AAA Elysia 猫猫侠 ⁧~喵andGitee da08c78238 bump(debian/changelog): 5.3.0-0
Signed-off-by: AAA Elysia 猫猫侠 ⁧~喵 <elysia-best@simplelinux.cn.eu.org>
2026-08-14 15:14:47 +00:00
shenmo7192andGitee 3403d1fb83 !409 docs &amp; ui: 新增测试打包脚本约定并优化首页间距
Merge pull request !409 from xiyidaiwa/Erotica
2026-08-14 15:10:26 +00:00
xiyidaiwa 7bad39a796 fix(security/robustness): 清理 update-center 调试日志 + cancel-install id 校验
- update-center/index.ts: 引入 pino logger,将约 30 处 console.log(含 [DEBUG] 前缀及敏感路径)
  替换为 logger.debug(生产默认 info 级不打印,消除敏感信息泄露)
- install-manager.ts: cancel-install 增加 id 整数类型校验(typeof number && isInteger),防御非法输入
- PR 审查其余 5 项经代码实证为误报:JSON.parse 已有 try-catch、activeDownloadCount 的 mkdir/axios
  均在 try 内且 finally 必执行、check/remove-installed 已用 parseAppPayload 做 PKGNAME_PATTERN 校验、
  origin 已枚举归一化、initializeUpdateCenter 已有单例守卫;async handler 顶层 try-catch 因 Electron
  主进程默认 unhandledRejection 不崩溃且改动面大(19 handler)风险高,未改
2026-08-13 23:37:03 +08:00
xiyidaiwa 3cedd0c517 fix(robustness): 截图探测被 abort 取消时不污染负缓存(维度2)
- checkScreenshotExists 原 catch 不区分异常类型,用户快速切换/关闭详情触发 abort 时,
  未完成的 HEAD 请求误写 false,导致同进程内再打开该应用少显示图片(重启才自愈)
- 改为 signal.aborted 时上抛错误:由 loadScreenshots 的 Promise.all reject 被 openDetail
  的 .catch 吞掉,既不写缓存也不覆盖 screenshots.value,后续新 controller 轮次正确填充
- 网络错误(非取消)仍正常写 false 过滤 404 脏数据;vue-tsc/lint/打包均通过
2026-08-13 23:09:10 +08:00
xiyidaiwa e67f4752a4 fix(stability): cancel-install 增加 SIGTERM→SIGKILL 超时降级防止僵尸进程
- install-manager.ts: cancel-install 仅 kill()(SIGTERM),子进程忽略时成僵尸;新增 forceKill 封装先 SIGTERM 后 5s 超时降级 SIGKILL
- PR 审查其余 6 项经代码实证均为误报/可维护性建议,未改动:JSON.parse 已有 try-catch、pkgname 已 PKGNAME_PATTERN 校验防穿越、shell-caller 已双引号+白名单、watch 不修改 apps.length 无循环、baseApps 量级无需 shallowRef、install-complete 已 off 清理
2026-08-13 22:49:36 +08:00
xiyidaiwa cdd94c650d fix(security): PR 审查整改 - setWindowOpenHandler 域名白名单 / openWebsite 协议校验 / 批量云端安装并发控制
- electron/main/index.ts: setWindowOpenHandler 增加 ALLOWED_EXTERNAL_HOSTS 域名后缀白名单,仅可信 https 域名可 shell.openExternal(阻断项2)
- AppDetailModal.vue: openWebsite 增加 http/https 协议校验 + noopener,noreferrer(改进项5)
- useAccountSync.ts: installCloudItems 改为分批(BATCH_SIZE=3) + Promise.allSettled 错误收集(改进项2)
- 审查误报/未改动项已贴代码实证:阻断1 v-html 已用 textContent 转义;改进3 resolveCloudInstallCandidate 已有降级匹配链;改进4 单窗口无 HMR 不重复注册;改进1 暂停/恢复主进程无 handler 维持 TODO
2026-08-13 22:38:26 +08:00
xiyidaiwa 72d107c9de fix: 截图以 img_urls 为候选并探测过滤 404(解决飞书类脏数据)
- 实测飞书 bytedance-feishu-stable 的 app.json img_urls 声明 5 张
  (screen_1~5),但 screen_4/5 实际 404(后端元数据声明与实际文件不一致)
- 上一版(.4)直接采用 img_urls 会把 2 个 404 URL 塞进预览,仍出现空白页
- 改为:以 img_urls 为候选集合,对每个 URL 做 HEAD 存在性探测,仅保留真实可用的
  (飞书过滤后只剩 3 张,微信 3 张保持 3 张)——既保证实际有几张预览几张,又兜底
  后端脏数据;探测结果按 URL 内存缓存复用,同一应用反复打开不重复请求
- 恢复 abort 机制:打开新详情/关闭时取消旧探测,避免陈旧结果覆盖
- debian/changelog 测试版本号 5.2.1.5-test
- 验证:curl 实测飞书 screen_4/5=404、微信 3 张全 200;vue-tsc 0 / eslint 0 / 打包成功
2026-08-13 22:30:38 +08:00
xiyidaiwa 8e1de74cab fix: 截图直接采用后端 img_urls,移除 HEAD 探测
- 实测后端 app.json 的 img_urls 为真实权威截图列表(微信 3 张即返回 3 个 URL,
  spk-json 与 erotica 两域名同路径 screen_1~3=200、screen_4~5=404)
- loadScreenshots 改为直接读取 app.img_urls(兼容 string[] 与字符串形式 JSON),
  由 ScreenPreview 的 screenshots.length 自动收敛预览数量与边界
- 彻底移除每次打开详情页的 5 个并发 HEAD 探测请求,消除服务器压力隐患
- 移除 checkScreenshotExists / screenshotAbortController / axiosInstance 依赖
- debian/changelog 测试版本号 5.2.1.4-test
- 验证:curl 实测 img_urls 真实存在;vue-tsc 0 / eslint 0 / 打包成功
2026-08-13 22:15:46 +08:00
xiyidaiwa d8bea4ac23 fix: 应用截图按实际存在数量预览,不再固定 5 张
- useAppDetail.loadScreenshots 改为异步探测:并发 HEAD 请求 screen_1~5.png,
  仅把返回 2xx 的真实截图加入 screenshots,不足 5 张时预览计数与按钮边界自动跟随
- ScreenPreview 已有的 disabled 边界 (currentScreenIndex === screenshots.length-1)
  因此生效,切换到最后一张后禁止继续向后,消除空白页
- 新增 screenshotAbortController:打开新详情/关闭详情时取消旧探测,避免结果覆盖
- 探测异常不影响详情页弹窗,截图区域在探测完成后自动刷新/显示暂无应用截图
- debian/changelog 测试版本号 5.2.1.3-test
- 验证:vue-tsc 0 / eslint 0 / dpkg-buildpackage 打包成功
2026-08-13 21:54:08 +08:00
xiyidaiwa e1edf1dcd5 fix: fetchWithRetry 对 429 限流增加指数退避与 Retry-After 尊重
- useHttp.calculateRetryDelay:优先读服务端 Retry-After 头;429 用指数退避
  (1000*2^attempt, 封顶 30s);其余 5xx 仍固定延迟,避免限流场景频繁重试浪费带宽
- debian/changelog 测试版本号 5.2.1.2-test
- 验证:vue-tsc 0 / eslint 0 / dpkg-buildpackage 打包成功

审查项实证(其余 3 项均误报/已有防护,不改):
- ① install-manager pkgname 二次校验:已有 PKGNAME_PATTERN(/^[a-zA-Z0-9._+-]+$/)
  在 parseAppPayload/addInstallTask/launch-app 三入口校验,且 spawn 全用 shell:false
  参数逐元素传递,命令注入不可达;审查建议的 replace 属冗余纵深防御,不改
- ② InstalledAppsModal /var/lib/ 宽泛:误报,真实白名单(400-409行)已是
  /var/lib/apm/ 与 /var/lib/dpkg/info/ 具体子目录,无宽泛 /var/lib/ 项
- ③ useAppState.ts 注入模式缺注册检查:误报,useAppState.ts 无 registerRequireLogin;
  真实 registerRequireLogin 在 useFavorites/useAppDetail,默认 ()=>true 为刻意安全兜底
  (未注册=放行),审查建议的 InjectionPoint 包装会破坏该语义,不改
2026-08-13 21:02:12 +08:00
xiyidaiwa 7be90f13ce fix: 排序魔法数字提取常量 + sidebar-config 远程校验类型守卫
- InstalledAppsModal 提取 ORIGIN_PRIORITY 具名常量与 originPriority(),
  消除排序比较函数中的 0/1 魔法数字(保留 hasOrigin 双来源语义)
- useCatalog.loadSidebarConfig 新增 isValidSidebarEntry 类型守卫,对远程
  sidebar-config.json 入口做字段类型/长度/type 白名单校验,防止畸形数据进入
  渲染层(type 白名单与 typedefinition.ts 的 SidebarEntry.type 严格一致)
- debian/changelog 测试版本号 5.2.1.1-test
- 验证:vue-tsc 0 / eslint 0 / dpkg-buildpackage 打包成功

审查项实证:
- 改进③(filteredApps 多次 filter) 误报:当前已为单 filter 遍历(.24 已改),
  审查贴旧片段且退化 a.origin 会破坏双来源,不改
- 改进①/② 为真改进,已采纳并保真实现
2026-08-13 20:43:43 +08:00
xiyidaiwa 0f62a7e36a feat(update-center): 支持被系统锁定(hold)包的强制安装与更新中心体验优化
- 标记 apt-mark hold 的 spark(aptss) 源包,默认禁用勾选并提示开启强制安装
- 更新中心发起更新时透传 forceHeld,后端 Spark 本地 deb 走 shell-caller
  force-ssinstall 分支(单次免密 pkexec:unhold→ssinstall→hold),避免二次提权
- 无可选项时禁用全选并提示"无可更新项";更新选中后从列表中移除已启动项
- DownloadDetail 紧凑布局避免整页滚动;Toolbar 全选与更新选中按钮同行
- 继承 PKGNAME_PATTERN 与 metalinkUrl 白名单(https+*.spark-app.store+拒..)纵深防御
- apm 源无 hold 概念,forceHeld 在 apm 分支忽略(正确行为)
2026-08-13 16:42:21 +08:00
xiyidaiwa b5c5ab04b5 fix(update-center): 修复更新中心点击更新后卡在"开始更新..."不动
- 根因:service.ts 用 webContents.send("queue-install") 向主下载队列发任务,
  但 webContents.send 的目标对象是渲染进程,主进程 ipcMain 监听不到自己发出的消息,
  导致任务从未进入 install-manager 的下载队列,UI 永远卡在 queued。
- 将 install-manager.ts 的 queue-install 处理逻辑抽为导出的 addInstallTask(payload, sender),
  新增 QueueInstallPayload 类型,避免 any。
- 更新中心 service.ts 的 start() 改为直接 await addInstallTask(...),任务真正入队。
- 文档 docs/app-list-cache-analysis.md 新增第 9 节记录本修复。
2026-08-13 09:37:17 +08:00
xiyidaiwa d7bdcf3680 fix: 标签策略启动初始化加固 + 已安装应用过滤/统计性能优化
- 标签优先显示策略 initTagPriorityStrategy 提到 App.vue onMounted 统一调用,
  消除组件挂载顺序导致先读内存默认 auto 的竞态窗口(AppDetailModal 移除重复调用)
- InstalledAppsModal 过滤逻辑合并为单次遍历(保留 hasOrigin 双来源语义)
- 统计 apm/spark/total 改为基于 searchFilteredApps 单次遍历
- canOpenDetail 补充业务含义注释与可读性变量
- 将 App.vue 重构的 9 个 composable 纳入版本管理
- 验证:vue-tsc 0 / eslint 0 / dpkg-buildpackage 打包 spark-store_5.2.1.24-test 成功
2026-08-12 23:16:17 +08:00
xiyidaiwa 5e7d88ecc5 fix(审计修复): 缓存穿透TTL共享 + 预刷新网络/超时 + 最大化/APM优先设计注释
- 新增 cacheBusterInterceptor.ts:createCacheBusterInterceptor(ttl=5min) 带 TTL 复用戳,
  解决同会话频繁击穿缓存(B1)并将 App.vue/storeConfig 两处重复逻辑合并为共享工厂(I3)
- storeConfig.ts: priorityConfigAxios 改用共享拦截器;HYBRID_DEFAULT_PRIORITY 加注释
  明确「主推 APM 是既定产品方向」(B2,非缺陷)
- App.vue: axiosInstance 改用共享拦截器(带 TTL)
- index.ts: 预刷新 startSourcePreRefreshOnce 加 net.isOnline() 检查 + 60s 超时兜底,
  避免离线/卡死触发 pkexec 弹窗困惑(I1);createWindow 恢复逻辑加注释明确
  「最大化后恢复重置为默认尺寸是有意设计,非缺陷」(I2)
- vue-tsc + eslint 通过
2026-08-12 21:02:48 +08:00
xiyidaiwa a7b8cd52af fix(安全/性能): AI审查实证修复 - metalinkUrl SSRF校验 + withTimeout定时器泄漏 + 批量更新/初始加载守卫
- install-manager.ts: queue-install 增加 metalinkUrl 校验(仅允许相对路径或官方域名,防 SSRF)
- App.vue: withTimeout 用 finally(clearTimeout) 修复定时器泄漏
- App.vue: apps 批量更新改数组赋值替代 push(...),避免逐元素响应式重渲染
- App.vue: apps.length watcher 增加 initialCatalogLoaded 守卫,初始目录加载期间跳过已安装列表刷新 IPC
- 审计实证:①filename校验已有(误报) ④pauseDownload为历史TODO且后端不支持(不在此PR) ⑦⑧为可维护性大项(记录待后续)

注:AI审查4阻断项实证——①误报 ②真实加固 ③真实修复 ④历史遗留;4改进项 ⑤⑥真实已修 ⑦⑧记录
2026-08-12 20:28:27 +08:00
xiyidaiwa 30e1bc4840 refactor(更新中心): E方案重设计更新条目 + 已忽略沉底 + 复用app.update显示更新时间 + 安全加固
- UpdateCenterItem.vue: 改为单行紧凑卡片,显示 包名·当前→新版本、来源标签、更新时间(品牌色)、大小;已忽略项淡化
- updateCenter.ts: filteredItems 将已忽略项沉底到列表末尾
- UpdateCenterList/Modal + App.vue: 透传全局 apps,用 app.json 的 Update 字段(Date.parse)推算 updateTime,零额外网络请求
- typedefinition.ts + update-center/types.ts: 新增可选 updateTime 字段
- install-manager.ts: launch-app 补 pkgname.length > 256 长度限制(防 DoS)
- index.ts: close 事件先 clearTimeout 防抖定时器再同步保存窗口状态(消除竞态)
- App.vue: fetchWithRetry 重试 3→2、延迟 1000→500ms(体验优化)
- 7维审计通过;AI审查2阻断项(getIconUrl防护/fetchWithRetry泛型)经核实为误报
2026-08-11 23:54:00 +08:00
xiyidaiwa aef9ccd1ec fix(主题/UI): 修复标题栏主题按钮事件链、社区版徽标方案B、侧边栏顶部块清理
- App.vue: WindowTitleBar 补 :theme-mode 与 @toggle-theme 绑定,使标题栏主题按钮真正触发 toggleTheme;watch(themeMode) 补 syncThemePreference() 立即落地 dark 类
- WindowTitleBar.vue: 新增 ThemeToggle 主题切换按钮;社区版标签改为方案B描边霓虹徽章(亮/暗双态)
- AppSidebar.vue: 删除侧边栏顶部 logo/账户菜单/主题切换块,导航容器滚动条还原为 scrollbar-muted,清理相关 script 死代码
2026-08-11 22:45:37 +08:00
xiyidaiwa 7cafcc51f6 fix(download): 下载失败时补全日志,避免卡在"正在获取 Metalink"突兀退出
- 主进程 Metalink 下载请求失败显式 sendLog 并回传渲染端(原仅静默 logger.error)
- aria2c 拉起失败补 sendLog 日志
- Metalink 写入完成补 sendLog 提示
- 渲染端 install-complete 失败分支将错误原因写入 logs,并补成功/失败终止日志
- 配套: 缓存修复(C1 主进程 no-cache + C2 axios ?_t 版本戳) 已在上一提交实现
- 文档 docs/app-list-cache-analysis.md 新增第8节下载日志断层修复方案
2026-08-11 18:50:31 +08:00
xiyidaiwa e0e6c50b89 fix(cache): 列表/分类请求禁用客户端缓存,根治新应用上架后搜不到
- C1: 主进程 onBeforeSendHeaders 对 .json 数据请求注入 Cache-Control: no-cache + Pragma
- C2: 渲染端 axiosInstance 与 priorityConfigAxios 对 .json GET 追加 ?_t 时间戳版本戳,穿透 CDN 边缘缓存
- 按去除 query 的 pathname 判断 .json,兼容 C2 自身追加的 ?_t,避免 C1/C2 交互失效
- 根因: nginx 无 Cache-Control 头 -> Chromium 启发式缓存(≈(now-LastModified)/10) 复用陈旧副本,重启仍命中旧 JSON,仅删 Cache 目录才生效
2026-08-11 18:44:32 +08:00
xiyidaiwa 8888afd4f5 fix: PR 审查真实缺陷修复(图标错位/定时器清理/并发/统计同步)
- AppDetailModal: iconPath 改用 viewingOrigin 动态计算,修复合并源切换时图标与数据错位
- App.vue: onUnmounted 清理 saveBoundsTimer 防抖定时器,避免组件销毁后悬空 IPC 调用
- App.vue: loadHomeListEntries 串行 for-of 改 Promise.all 并发拉取,缩短首页入口加载
- InstalledAppsModal: 顶部统计徽章基于搜索过滤结果实时计算,修复搜索时数字不同步

(审查误报项已核实未改: fetchAppFromStore 已有 encodeURIComponent + AbortSignal)
2026-08-11 00:20:19 +08:00
xiyidaiwa 44fa822146 fix: 排行榜日期比较鲁棒性 + 下载计数请求取消
- ranking.ts: topByUpdate 改用时戳数字比较,兼容后端不一致的日期格式
  (如 2026-08-10 / 2026/08/10 / 带时分秒),无效日期兜底为 0 避免 NaN 乱序
- App.vue: fetchDownloadCount 的 fetch 加 rootAbortController.signal,
  组件卸载时可取消未完成请求,避免内存泄漏

(注: 这两个真实缺陷由 PR 审查在错误 diff 下点名,本提交独立于 Erotica PR)
2026-08-10 23:59:15 +08:00
xiyidaiwa 102ac72d89 fix: 安全加固 + 首页推荐优先加载解耦
- install-manager: queue-install JSON.parse 加 try-catch 防异常 payload
- App.vue: openExternalUrl 协议白名单(http/https) 防恶意 scheme
- AppDetailModal: sanitizeMoreContent 改用 textContent 防 v-html XSS
- storeConfig: loadPriorityConfig 校验顶层结构,畸形数据回退 APM
- App.vue onMounted: 首页推荐(loadHome+preloadHomeListApps) 与全量
  应用加载(loadApps) 解耦,进入软件即优先显示首页推荐
2026-08-10 23:40:21 +08:00
xiyidaiwa 5bd0a367c4 refactor(update-center): 消除刷新重试代码重复并加对称设计注释
- updateCenter.ts: 提取 getBackoffDelay,补充与主进程预刷新为对称设计的说明
- index.ts: 提取 getPreRefreshBackoffDelay,对齐命名与注释
- 仅消除重复拼写、补注释,刷新重试功能行为不变
- 超时保护仍只作用于刷新源路径,不波及列表加载与其他 IPC

验证: eslint + vue-tsc 通过,test-build 5.2.1.14-test 通过
2026-08-10 23:14:18 +08:00
xiyidaiwa 3f22207505 fix/refactor: 更新中心扫描与多项安全加固(经专业审计)
更新中心扫描修复:
- updateCenter.ts: 抽出 runSystemUpdateThenLoad, open/refresh 共用,
  打开时即刷新双源(aptss ssupdate + apm update), 失败仅告警不阻断扫描
- shell-caller.sh: ssupdate 分支 exit $? 恒 0 吞掉刷新失败, 改为 exit $exit_code
- update-center/query.ts: 移除 nextVersion===currentVersion 误删真实更新项逻辑,
  信任 aptss 上游 upgradable 判断

安全加固:
- AppDetailModal.vue: 新增 sanitizeMoreContent 剥除 HTML 标签后再 v-html, 防 XSS
- InstalledAppsModal.vue: ALLOWED_LOCAL_ICON_PREFIXES 收紧为具体子目录, 缩小
  本地文件读取面
- install-manager.ts: filename 用 path.basename 防路径遍历; 包名/文件名 PKGNAME_PATTERN 校验
- index.ts: 临时目录改用 spark-store-${pid} 隔离, will-quit 清理对应目录

经 7 维度专业审计(安全/功能/类型/可维护/资源/性能/兼容)通过。
2026-08-10 21:42:36 +08:00
xiyidaiwa 0be6f8ad11 fix: 修复更新中心打开时扫描不到软件更新的问题
- shell-caller.sh: ssupdate 分支 exit $? 永远返回 0 会吞掉刷新失败,
  改为 exit $exit_code 真实上报 aptss ssupdate 成败
- updateCenter.ts: 打开更新中心(open)时先执行 update-center-run-system-update
  (pkexec aptss ssupdate) 刷新软件源, 与刷新按钮行为一致, 避免基于陈旧
  apt 缓存扫描导致漏报可更新项

经多维度专业审查(安全/功能/类型/可维护/资源/性能/兼容)通过。
2026-08-10 21:21:40 +08:00
xiyidaiwa 800a92b419 fix: 处理 PR 审查意见(竞态/类型安全/冗余/日志泄露)
- App.vue fetchAppFromStore 的 fetch 接入 rootAbortController.signal,
  组件卸载时取消请求,避免竞态与内存泄漏;catch 静默处理 AbortError。
- App.vue loadHome 远程字段由 `as string` 断言改为 typeof 运行时守卫,
  避免非字符串数据注入响应式状态(非崩溃,属改进)。
- ranking.ts topUpdatedContributors 移除对 topByUpdate 已过滤结果的冗余 filter。
- storeConfig 加载成功日志改为仅打印规则条数,避免完整配置泄露且保留诊断价值。
- 未采纳:parseAppPayload 默认 origin=spark 维持不变(spark 为历史默认源,
  盲改 apm 有反向误路由风险,且该字符串分支仅为极旧格式兜底)。
2026-08-10 17:19:55 +08:00
xiyidaiwa 9247fe8e63 fix: 合并应用子版相反命中时按 apmPriority 例外优先裁决
getHybridDefaultOrigin 原先对合并应用子版采用「spark 先于 apm」短路返回,
当 sparkApp 命中 sparkPriority 且 apmApp 命中 apmPriority(方向相反)时,
会错误返回 spark,违反 priority-config.json 中 apmPriority「例外优先」语义。
改为先收集两子版命中结果,方向相反时让 apm 胜出;仅单子版命中或同向时
维持原行为。已用 Node 复现验证(相反命中返回 apm、对照旧逻辑返回 spark)。
2026-08-10 08:27:18 +08:00
xiyidaiwa 7ac1b2f0b6 refactor: 按代码审查清理标签优先显示策略相关代码
- 移除 AppDetailModal auto 决策分支的调试 console.log(生产不残留调试日志)
- 删除 storeConfig 中无读取方的 isPriorityConfigLoaded/hasPriorityConfigFile
  死变量,并修正 catch 分支借 resetPriorityConfig 误标 loaded 的语义矛盾
- getHybridDefaultOrigin 对合并应用子版匹配时,子版 category 为空则
  回退顶层 app.category,避免分类规则漏匹配
- 补充 forceViewingOrigin 强制来源不随设置策略实时切换的注释(设计行为)
2026-08-10 08:11:13 +08:00
xiyidaiwa b800f3164b Merge branch 'Erotica' of gitee.com:xiyidaiwa/spark-store into Erotica
# Conflicts:
#	src/App.vue
2026-08-07 18:23:35 +08:00
xiyidaiwa b3eb1d01d9 fix: 标签优先显示策略 auto 模式按服务端优先级配置展示
- auto 模式用合并应用自身(含 sparkApp/apmApp)匹配 priority-config.json,
  不再仅取单一子版,避免漏匹配。
- openDetail 区分「已安装页显式来源强制」与「其他页面按策略」;
  非强制分支清除 forceViewingOrigin 粘性标志,避免跨入口状态泄漏。
- AppDetailModal 按 TagPriorityStrategy 响应式重算默认来源;
  手动切标签仅本次会话预览,不持久。
- storeConfig.loadPriorityConfig 改用 axios(与渲染进程一致),
  修复 Electron 下全局 fetch 失败导致配置为空、auto 全部回退 APM 的问题。
- 首页推荐列表 parseAppList 从 jsonUrl 提取分类,避免 category 误为 unknown。
2026-08-07 18:21:39 +08:00
xiyidaiwa 88499a56f8 feat: 新增「标签优先显示策略」设置(自动选择/Spark优先/APM优先,默认自动选择)
需求:在应用设置中新增标签优先显示策略配置,进入应用详情页时按所选策略决定
默认展示的来源(Spark/APM)标签;选中的标签即为安装时对应的来源。

实现:
- 新增 src/global/tagPriority.ts:策略('auto'|'spark'|'apm')共享模块,localStorage 持久化,
  通过共享 ref 在设置页与详情页间同步(纯前端显示偏好,不触及后端安装脚本)。
- SettingsModal.vue:在「安装设置」中新增三选项分段控件(默认选中「自动选择」),
  打开时加载、变更时即时保存。
- AppDetailModal.vue:新增 computeDefaultViewingOrigin,按策略计算默认 viewingOrigin:
  自动选择→getHybridDefaultOrigin(应用配置优先级);Spark/APM 优先→优先选中对应标签,
  若应用无该来源标签则回退优先级策略;仅有一个来源标签时默认展示该标签;
  父组件显式指定的 viewingOrigin 仍具最高优先级。

vue-tsc --noEmit 通过。
2026-08-07 10:45:44 +08:00
xiyidaiwa ae584730c4 refactor: 多维度代码审查修复(安全/清理/类型)
- src/App.vue:
  - fetchAppFromStore 的 category/pkgname 路径参数 encodeURIComponent 编码(防路径穿越)
  - img_urls JSON.parse 增加 try/catch,解析失败回退空数组(两处)
  - onUnmounted 统一移除 hashchange/resize/keydown 及全部 IPC 监听,修复内存泄漏
  - 提取 createFallbackApp 消除 openDetail 重复回退构造
  - 收集并统一停止等待 loading 的一次性 watcher,闭环生命周期清理
- src/global/typedefinition.ts: 移除 CategoryInfo/HomeLink 宽泛索引签名,
  CategoryInfo 改用显式 extra 字段
- electron/main/backend/install-manager.ts: 清理冗余 eslint-disable 并修正缩进
  (remove-installed/check-installed 已通过 parseAppPayload 完成 pkgname 校验,无需改动)
2026-08-06 22:06:43 +08:00
xiyidaiwa 0d580c4d47 fix: 修复 before-quit 中 mainWindow 未定义报错,并优化排行榜加载
- electron/main/index.ts: flushSaveBounds 改用模块级 win 变量,
  修复关闭应用时 mainWindow is not defined 的 ReferenceError
- src/App.vue: loadRanking 并发数降至 15;loadHome 多来源请求并行化
- src/modules/ranking.ts: parseContributors 增强异常格式边界处理
2026-08-06 20:51:47 +08:00
xiyidaiwa c1d2bef38a ui: 隐藏首页致谢卡片(代码保留,v-if=false) 2026-08-03 14:35:05 +08:00
xiyidaiwa 3816703303 fix: 安全加固——queue-install 路径遍历校验 + 首页远程数据显式字段
针对 PR 安全审查的修复:

1. queue-install 路径遍历(阻断项,真实风险):
   install-manager.ts 直接用渲染进程传入的 pkgname 拼装下载目录
   /tmp/spark-store/download/ 与 ssinstall 命令参数 /,
   此前未做任何校验。复用模块顶部 PKGNAME_PATTERN 对 pkgname 与 filename 做白名单校验,
   拦截含 ../、/ 或非法字符的输入,杜绝路径遍历。

2. 首页远程数据展开(阻断项,纵深防御):
   App.vue loadHome 此前用 { ...l } 直接展开远程 homelinks.json 到响应式数组,
   可能注入未知属性。改为显式提取 name/url/icon/more/imgUrl/type/origin 已知字段构造,
   仅保留可信字段(url 仍做必需校验以保首页正常渲染)。

注:check-installed 白名单(审查第1项)此前已在 4f772ba7 通过 parseAppPayload 修复,本次无改动。

vue-tsc --noEmit 通过。
2026-08-03 14:17:45 +08:00
xiyidaiwa 1569dad866 fix: 修复首页内容因 icon 字段校验被全部过滤的回归
回顾:上轮健壮性修改中,loadHome 对 homelinks.json 的解析新增了
'if (!url || !icon) continue' 的硬性校验。但远程 homelinks.json 实际
仅提供 name/more/imgUrl/type/url,并不含 icon 字段(首页图片统一由
imgUrl 提供),导致所有链接因缺少 icon 被跳过,首页区域1 变空白。

修正:
- App.vue loadHome:移除对 icon 的强制校验,仅校验 url 必需;icon 缺省为空串以兼容类型
- typedefinition HomeLink.icon:由必需 string 改为可选,反映真实数据源契约

vue-tsc --noEmit 通过,已重新打包验证(5.2.1.2-test)。
2026-08-03 12:05:57 +08:00
xiyidaiwa 4f772ba7eb fix: 安全与健壮性排查修复(IPC 类型校验/AbortSignal/空名去重/窗口 await)
阻断项:
- install-manager: uninstall-installed / remove-installed / check-installed 由 any 改为 unknown + parseAppPayload 严格解析(PKGNAME_PATTERN 校验 pkgname,origin 仅限 spark|apm),消除命令注入风险;parseAppPayload/PKGNAME_PATTERN 提升至模块顶部复用并避免 TDZ
- App.vue openDetail: 参数由 Record<string,unknown> 收紧为 App | OpenDetailInput,新增 pkgname 运行时校验,避免访问 undefined 属性

改进项:
- App.vue homeLinks 去重:跳过空名称避免污染 seenNames,并对 links 数组与 HomeLink 必需字段做校验
- App.vue fetchWithRetry: 新增 AbortSignal 参数,递归链路传递,组件卸载时 rootAbortController.abort() 取消未完成任务
- index.ts showAndFocusMainWindow: 改为 async 并 await createWindow,调用点用 void 修饰避免 floating promise

vue-tsc --noEmit 通过。
2026-08-03 11:24:32 +08:00
xiyidaiwa b89c001401 refactor: 基于 upstream/Erotica 重新整合本地功能(来源选择/合并详情/计数/窗口/首页)
将本地在 upstream !408(已安装应用合并+统计)之上的后续改动重新整合到最新 upstream/Erotica:
- 已安装应用:卸载/打开按 APM/Spark 来源选择;计数总数=APM+Spark 包数(双来源计为独立包)
- 查看详情:已安装页复用所有应用页合并详情视图(单来源优先已装类型,双来源自动模式)
- 窗口:恢复阈值改为 1600x900,过大回退默认尺寸并居中,不自动最大化
- 首页推荐:致谢卡片移至其他内容下方,加大标题/卡片/致谢间距
- 隐藏排行榜/荣耀榜侧栏入口(代码保留,v-if=false)
- 新增 scripts/test-build.sh 测试打包脚本(版本末段+1 加 -test)并更新 AGENTS.md

冲突文件(index.ts/App.vue/InstalledAppsModal.vue)均取本地完整实现(为上游版本的超集)。
2026-08-03 11:06:28 +08:00
xiyidaiwaandshenmo7192 77b4244f1d !408 refactor(已安装应用): 合并 APM/Spark 分页为列表标签并新增统计
Merge pull request !408 from xiyidaiwa/Erotica
2026-07-29 06:47:41 +00:00
gfdgd-xi c1b89cab1a 支持从git提取贡献者信息
Signed-off-by: gfdgd_xi <3025613752@qq.com>
2026-07-27 16:22:57 +08:00
shenmo7192andGitee 17cbb7cff2 !407 style: 统一滚动条外观并将投稿应用窗口改为圆角
Merge pull request !407 from xiyidaiwa/Erotica
2026-07-24 14:13:49 +00:00
shenmo7192 bcf3252e4a style(app-detail): add tooltip text for install buttons
为应用详情页和详情弹窗的两个安装按钮分别添加了对应的提示文案,提升用户体验
2026-07-24 10:26:40 +08:00
xiyidaiwa 910aec6cce fix(app): 修复应用计数统计问题
- 将应用总数统计从 apps.value.length 改为 baseApps.value.length
- 确保分类统计基于正确的基础应用数据
- 解决了应用过滤后计数不准确的问题
2026-07-23 18:41:19 +08:00
xiyidaiwa 3d28010797 fix: 处理 PR 评审意见(副作用清理、滚动条分层与变量化)
- SubmitterWindow: onMounted 修改 body/html 背景后,在 onUnmounted 中恢复原始值,避免副作用泄漏。
- appstyle.css: 全局滚动条基础几何/颜色移至 @layer base,使 .scrollbar-muted/.scrollbar-nowidth 工具类优先级更高;
  暗色 thumb 改用专用变量 --color-scrollbar-thumb-dark (#15203b),保留比 surface 略亮的可辨识反差(未改用 --color-surface-dark)。
- 评审建议"margin 改 padding"与"改用 .scrollbar-muted"经分析不成立:padding 不会内移滚动条、无法避开圆角,margin 才能把容器连同滚动条推离圆角;
  .scrollbar-muted 为 0.4rem 细条,与其他页面 0.75rem 全局不一致,故保持 mr-4 mb-4 全局样式。
2026-07-22 01:39:52 +08:00
xiyidaiwa df8c2d7eaa style: 统一滚动条外观并将投稿应用窗口改为圆角
- 滚动条:主进程对 Linux 关闭 overlay 滚动条
  (disable-features=OverlayScrollbar),使 CSS ::-webkit-scrollbar 自定义生效;
  全局统一定义亮色 #94a3b8 / 暗色 #15203b(带极淡描边),宽度 0.75rem,亮暗一致。
- 投稿页:修复滚动条贯穿整个窗口、侵入标题栏(改为 flex 分栏,
  标题栏 shrink-0,内容区单独滚动)。
- 应用管理页:移除 scrollbar-nowidth 隐藏与 scrollbar-thumb-* 覆盖色,
  改由全局规则统一,与其他页面一致。
- 圆角页面:投稿页与已安装应用页滚动容器内缩 (mr-4 mb-4),
  避免滚动条滚入圆角被裁切。
- 投稿应用窗口:独立窗口改为透明圆角窗口 (transparent + rounded-3xl + shadow-2xl),
  与弹框风格统一;挂载时 body 背景透明以露出圆角。
- 标题栏:左上角蓝色圆点替换为软件图标 spark-store.svg。
- 更新中心:搜索框对齐主页样式(左侧图标、统一圆角与配色)。
2026-07-22 01:19:52 +08:00
shenmo7192 e857ce67b3 move spark-store launcher from extra to tools 2026-07-20 11:20:20 +08:00
shenmo7192andGitee 98296fa8a3 update tool/ssinstall.
Signed-off-by: shenmo <jifengshenmo@outlook.com>
2026-07-20 03:11:55 +00:00
shenmo7192andGitee 96541cc13e update tool/aptss.
Signed-off-by: shenmo <jifengshenmo@outlook.com>
2026-07-20 03:09:13 +00:00
shenmo7192andGitee b84f82e7bb 上游标准化
Signed-off-by: shenmo <jifengshenmo@outlook.com>
2026-07-20 03:07:55 +00:00
shenmo7192andGitee e7476f4bc3 update tool/aptss.
Signed-off-by: shenmo <jifengshenmo@outlook.com>
2026-07-20 03:05:13 +00:00
shenmo7192 0a6fcd95c7 feat(install-manager): 安装成功时跳过APM快捷方式创建,仅针对升级任务 2026-07-19 23:27:19 +08:00
shenmo7192 99c6048295 修复 UOS 不识别 oneshot 的问题 2026-07-18 23:02:40 +08:00
shenmo7192 3b7f14862d refactor(DownloadQueue): 移除无用的滚轮隐藏队列逻辑与相关依赖
移除了页面滚轮事件监听、isHidden状态和相关的DOM引用,简化组件逻辑
2026-07-18 22:11:54 +08:00
shenmo7192 367828f1f4 chore(vscode & download queue): 修复调试配置和下载面板交互问题
1. 给VSCode调试配置添加--no-sandbox参数以解决容器/权限问题
2. 修复下载队列面板收起时点击无响应的bug,调整隐藏状态的位移参数
3. 优化展开/收起切换逻辑,隐藏状态下点击会自动展开面板
2026-07-18 22:04:23 +08:00
shenmo7192 70250acc76 fix(submitter): 捕获打开输出文件夹失败的错误
新增错误捕获逻辑,避免打开打包完成的文件夹失败时导致程序报错
2026-07-18 12:48:49 +08:00
shenmo7192andGitee 3157714776 !406 fix: 仅在使用loonggpu时禁用显卡加速
Merge pull request !406 from AAA Elysia 猫猫侠 ⁧~喵/N/A
2026-07-18 04:30:26 +00:00
AAA Elysia 猫猫侠 ⁧~喵andGitee bfd7626b8f fix: 仅在使用loonggpu时禁用显卡加速
Signed-off-by: AAA Elysia 猫猫侠 ⁧~喵 <elysia-best@simplelinux.cn.eu.org>
2026-07-18 04:27:53 +00:00
shenmo7192 5d512c4e86 add: 龙芯禁用GPU 2026-07-18 11:34:59 +08:00
shenmo7192 9323c368d6 style: 统一文件名小写处理并格式化代码
1. 对deb包名、打包文件名统一转为小写格式,避免大小写不一致问题
2. 格式化多处代码换行和缩进,提升代码可读性
2026-07-18 10:37:52 +08:00
shenmo7192 1f9971f7aa chore(gitignore): ignore Debian build intermediates under debian/ 2026-07-18 10:34:56 +08:00
shenmo7192 d67f7b3666 feat(submitter): 完善提交表单功能,优化交互与体验
1.  新增electronUtils接口用于获取文件路径,支持拖拽上传deb包
2.  交换贡献者与官网地址表单字段
3.  重构架构选择UI,按来源和架构排序并添加来源标识
4.  新增截图和图标上传格式校验,限制仅支持PNG
5.  优化截图上传区域样式,支持拖放上传
6.  拆分重置表单和提交成功后的状态清理逻辑
7.  重构架构排序逻辑,兼容新旧存储格式
2026-07-18 00:22:45 +08:00
shenmo7192 c2ba273ca2 update 5.2.1.0 2026-07-17 23:47:22 +08:00
shenmo7192 361dad1a64 feat(submitter): 新增自动获取git用户名并填充提交信息
扩展了提交器的自动填充能力,新增通过git config获取本地git用户名的能力,和原有邮箱获取逻辑结合,自动按照格式填充贡献者字段,优化用户提交体验
2026-07-17 23:12:10 +08:00
shenmo7192 5730c0bfea feat(SubmitterWindow): add view submission queue button
add a button to open the submission queue page in new tab, alongside the close button in the header
2026-07-17 22:52:03 +08:00
shenmo7192 e8a2bb921b refactor(electron+vue): 优化窗口拖拽和激活逻辑
1. 为提交窗口标题栏添加拖拽支持,同时关闭关闭按钮的拖拽区域
2. 重构窗口激活显示逻辑为复用函数showAndFocusMainWindow
3. 修复窗口实例管理和托盘图标点击的逻辑问题
2026-07-17 21:39:34 +08:00
shenmo7192 ca5e7b98ac build: 更新版本号为5.2.1-0以准备 Debian 包发布
同步修改了debian changelog和package相关文件的版本字段,适配 Debian 软件包的版本命名规范,完成初始打包准备工作
2026-07-17 21:18:37 +08:00
shenmo7192 5a01d95e0f feat(SubmitterWindow): add screenshot paste shortcut and refactor upload logic
新增截图粘贴上传功能,重构了截图上传的处理逻辑,将公共上传逻辑提取为importScreenshots方法,同时支持通过Ctrl+V粘贴截图直接上传,优化了截图文件的筛选和上传流程
2026-07-17 21:17:40 +08:00
shenmo7192 37217cd6fb chore: 发布v5.2.1版本并优化布局交互
1. 调整AppGrid和主容器的高度样式,替换calc(100vh - 140px)为100%适配布局
2. 为下载队列添加滚轮隐藏/显示动画和交互逻辑
3. 优化主内容区域的flex布局结构,修复滚动和容器收缩问题
2026-07-17 21:12:50 +08:00
shenmo7192 61f5cc954c 修改 makefile 适配构建 2026-07-16 17:37:28 +08:00
shenmo7192 7278153ec9 删除无用代码和工具 2026-07-16 16:56:40 +08:00
shenmo7192 2f93ff9727 修复打包问题 2026-07-16 16:55:05 +08:00
shenmo7192 f585be0dcd feat(submitter): add form validation tips and improve submission checks
1. add real-time form validation prompt component for submission page
2. add URL validation for official website and screenshot format checks
3. refactor form validity calculation and user input status judgment
4. replace mirror switch with configurable base URL, support dev proxy path handling
5. add processing logic for base64 format screenshots
6. optimize history search and form reset logic
2026-07-16 15:56:13 +08:00
shenmo7192 a8ea7bec34 refactor(submitter): 优化分类加载逻辑,支持按架构获取CDN分类
1. 提取公共的CDN分类获取函数,增加 fallback 兜底逻辑
2. 新增支持amd64、arm64等更多架构类型
3. 为每个架构单独加载对应的分类列表,适配不同架构的分类差异
4. 更新日志打印参数,更清晰展示各架构的分类数量
2026-07-16 14:27:26 +08:00
shenmo7192 b06966c0a7 chore: 移除systemd定时器相关的安装和文件
删除了spark-update-notifier.timer文件,同时移除Makefile中对应systemd目录的安装拷贝步骤
2026-07-16 14:18:24 +08:00
shenmo7192 e496449c31 refactor(app): adjust loading sequence of app data
调整了初始化时的数据加载顺序,将侧边栏预加载移至全部应用加载完成后执行,避免网络连接被抢占,同时简化了Promise调用逻辑
2026-07-16 14:04:27 +08:00
shenmo7192 0a908a31f6 chore: release v5.2.1 and improve submitter UI/UX
1. update version to 5.2.1 in changelog and package.json
2. add real-time upload progress display in submission window
3. optimize icon filename display and form reset logic
4. add success modal after successful submission
5. improve logging formatting in submitter backend
2026-07-16 14:03:40 +08:00
shenmo7192 68195a94f6 refactor(submitter): 重构分类处理逻辑,完善提交校验与安装包流程
1.  重构提交器的分类处理,移除硬编码的分类映射,改用后端返回的type字段
2.  添加categoryId同步逻辑,完善表单校验和提交数据
3.  优化图标处理逻辑,支持base64格式图标
4.  完善deb包元数据处理,自动生成规范的文件名
5.  新增更全面的提交字段校验和API业务错误处理
6.  补充安装脚本的目录复制规则
2026-07-16 13:57:39 +08:00
shenmo7192 387be920b1 refactor(apps): optimize home app list loading and grid layout
1. refactor home app list loading logic: use seen set instead of map, handle deduplication with spark priority
2. add vertical scrolling for small app grid when not using virtual scroll
3. remove unnecessary overflow-y-auto from main container
2026-07-16 10:27:18 +08:00
shenmo7192 c814c4b13d fix: 修复重复应用显示问题,按来源优先级去重
1. 为首页链接和应用列表添加去重逻辑,spark 来源优先覆盖 apm
2. 优化数据处理方式,使用 Set 和 Map 避免重复数据
3. 更新日志输出的统计数字为去重后的实际数量
2026-07-16 10:14:32 +08:00
shenmo7192andGitee 634b17ceb7 !405 fix: 简化构建流程,修复龙架构构建
Merge pull request !405 from AAA Elysia 猫猫侠 ⁧~喵/elysia/fix-loong64
2026-07-16 02:13:53 +00:00
elysia-best 649c9fb53b fix: 简化构建并修复龙架构构建
Signed-off-by: elysia-best <a.elysia@proton.me>
2026-07-16 01:06:49 +08:00
shenmo7192 5e120d03ae refactor(installer): 更新下载重试策略为指数退避
将原来固定间隔的重试超时列表替换为指数退避的重试间隔,从首次3秒递增到末次1分钟,共10次重试,优化下载失败后的重试体验
2026-07-16 00:44:42 +08:00
shenmo7192andGitee 28b6cbfced !404 fix(Makefile): fix loong64 build logic
Merge pull request !404 from AAA Elysia 猫猫侠 ⁧~喵/elysia/fix-loong64
2026-07-15 16:40:33 +00:00
shenmo7192 46f79ca6fc refactor(sidebar): 优化侧边栏加载逻辑,支持按来源仓库过滤请求
1. 新增SidebarEntry的origins字段标记入口所属仓库
2. 合并多仓库共享的侧边栏入口配置
3. 仅向实际存在的仓库发起分类和应用加载请求,减少无效404请求
4. 并行加载分类数据避免串行等待,调整预加载顺序优化网络资源占用
2026-07-16 00:40:38 +08:00
elysia-best c926019c76 chore: update package-lock.json 2026-07-15 22:50:12 +08:00
shenmo7192 0159f814b0 Merge remote-tracking branch 'refs/remotes/origin/Erotica' into Erotica 2026-07-15 20:25:17 +08:00
shenmo7192 c8783ed66c chore(release): prepare v5.2.0 stable release
1. update debian changelog version from 5.2.0~alpha1 to 5.2.0
2. remove deprecated CI workflow backup files
3. fix duplicate loading issue by adding loadingTabs state
4. add effectiveLoading computed state to handle per-tab loading status
2026-07-15 20:25:04 +08:00
elysia 520ace4955 fix(Makefile): fix loong64 build logic
Signed-off-by: elysia <a.elysia@proton.me>
2026-07-15 10:57:50 +08:00
shenmo7192andGitee 0e9e1aaccd !403 fix: 处理 systemd 服务单元每日 aptss update 触发逻辑
Merge pull request !403 from zinface/systemd/spark-update
2026-07-14 14:17:00 +00:00
shenmo7192 fffbb8c033 release: bump version to 5.2.0正式版 2026-07-14 14:22:06 +08:00
shenmo7192 937c0554ad chore: 暂时关闭云端同步、收藏和评论功能
调整了多个页面的相关UI和逻辑代码,注释掉并隐藏了收藏、评论以及云端同步相关的按钮和实现逻辑,同时修改了侧边栏的品牌文案展示。
2026-07-14 14:21:35 +08:00
shenmo7192 0858797405 perf(App.vue): 修复首页列表N+1请求问题,优化加载性能
直接使用接口返回的列表数据构建应用对象,不再为每个应用单独请求app.json,应用详情将在用户点击时按需获取
2026-07-14 14:08:17 +08:00
shenmo7192 7420466d92 style: 统一代码格式与字符串引号风格
1.  替换单引号为双引号统一字符串格式
2.  拆分过长的函数调用与条件判断行提升可读性
3.  简化多行console.log调用为单行格式
4.  为侧边栏添加条目计数标签展示功能
2026-07-14 13:59:55 +08:00
shenmo7192 750e1d89e4 feat: add SPK share link support and optimize category count calculation
- add SPK share button in app detail pages and support parsing SPK links in search box
- fix category total count to exclude the all entry
- remove redundant count badge from sidebar tabs
- rewrite category count calculation with proper filtering of all entry
- update search category count logic to use base apps for all tab count
2026-07-14 13:39:20 +08:00
shenmo7192 b233fea4d5 refactor(install): 重构下载安装队列逻辑,拆分并发控制
1.  将下载与安装流程分离,实现并发下载限制与单安装实例控制
2.  新增任务阶段枚举,替换原有activeDownloads计数逻辑
3.  优化取消任务的处理流程,区分运行中和排队中的任务
4.  修复下载队列显示的统计错误,改为展示已完成任务数
5.  调整日志格式与代码注释,提升可读性
2026-07-13 23:06:05 +08:00
shenmo7192 6328f178d3 refactor(home): 重构首页与侧边栏,移除旧HomeList类型并支持首页列表侧边栏入口
1.  移除废弃的HomeList类型定义,新增homeList侧边栏类型
2.  重构AppGrid组件,添加showOrigin属性支持自定义显示来源标记
3.  简化HomeView组件,移除旧的列表展示逻辑与相关代码
4.  新增侧边栏首页列表入口加载与应用数据加载逻辑
5.  优化侧边栏计数与应用预加载逻辑,适配新的首页列表类型
2026-07-13 22:57:54 +08:00
shenmo7192 7c6bdf0e55 feat(submitter,update-center): add multiple features and optimize submission flow
1. add shell caller ssupdate command support
2. add pre-update system refresh for update center
3. add get-git-email IPC handler and auto-fill email
4. optimize deb submission UI and category handling
5. disable and auto-fill package name/version fields
6. add loading and error states for category list
7. fix category value matching for history data
2026-07-13 22:07:01 +08:00
shenmo7192 0aca5744b0 refactor(SubmitterWindow): 调整deb上传组件的位置
将deb安装包上传区域从表单下方移动到上方,优化页面填写流程
2026-07-13 18:35:28 +08:00
shenmo7192 bfa959ca1d style: 移除所有组件的backdrop-blur和半透明背景效果
统一调整多处UI元素的样式,去掉不必要的backdrop-blur滤镜以及bg的透明度后缀,让界面视觉风格更简洁统一
2026-07-13 18:31:14 +08:00
shenmo7192 2bafa66004 style(ui): 调整弹窗和提交窗口的样式与布局
1. 为提交根容器添加overflow-y-auto防止内容溢出
2. 放大应用详情弹窗最大宽度从5xl到6xl
3. 提升弹窗和滚动区域的最大高度从85vh到90vh
4. 移除应用详情描述区域多余的overflow-y-auto
2026-07-13 18:28:27 +08:00
gfdgd-xi 91f144d3f2 使用electron 37.2.5
Signed-off-by: gfdgd_xi <3025613752@qq.com>
2026-07-13 16:52:33 +08:00
gfdgd-xiandGitee 8bef913f7a !400 投稿器用自渲染对话框替换alert修复选择deb后无法编辑信息的问题、拆分投稿器源码
Merge pull request !400 from gfdgd xi/Erotica
2026-07-13 08:10:06 +00:00
gfdgd-xi 07e7962211 降级使用electron 28.2.1
Signed-off-by: gfdgd_xi <3025613752@qq.com>
2026-07-13 08:09:23 +08:00
shenmo7192andGitee 365cf4d994 !401 fix: APM安装软件后无法自动创建快捷方式的问题
Merge pull request !401 from CharOfString/Erotica
2026-07-09 13:06:25 +00:00
zinface f5fe5ce417 fix: 处理 systemd 服务单元每日 aptss update触发逻辑 2026-07-02 16:01:05 +08:00
MarcusPy827 2efea67a60 chore: APM桌面快捷方式补丁修正权限0755 -> 0644 2026-06-30 03:44:57 +08:00
MarcusPy827 51d7e1870c chore: APM桌面快捷方式补丁改用异步读取+修复过早return 2026-06-30 03:43:21 +08:00
MarcusPy827 98d6ebd2b4 chore: APM桌面快捷方式补丁改用异步读取+从XDG拿路径 2026-06-30 03:27:36 +08:00
MarcusPy827 6921350878 fix: APM安装软件后无法自动创建快捷方式的问题 2026-06-30 03:13:28 +08:00
gfdgd-xi 5f2f07afbe 修复主窗口右侧有滚动条的问题
Signed-off-by: gfdgd_xi <3025613752@qq.com>
2026-06-29 22:53:36 +08:00
gfdgd-xi ebede13b2c 修复tag列表无法正确加载的问题
Signed-off-by: gfdgd_xi <3025613752@qq.com>
2026-06-29 22:26:19 +08:00
gfdgd-xi 829b5495b2 新增联系邮箱输入框
Signed-off-by: gfdgd_xi <3025613752@qq.com>
2026-06-29 22:07:43 +08:00
gfdgd-xi 5c64945517 移除投稿器源选择对话框
Signed-off-by: gfdgd_xi <3025613752@qq.com>
2026-06-27 18:52:54 +08:00
gfdgd-xi 2cab0b2161 投稿器用自渲染对话框替换alert修复选择deb后无法编辑信息的问题
Signed-off-by: gfdgd_xi <3025613752@qq.com>
2026-06-27 18:45:22 +08:00
gfdgd-xi 8d642c43ef 拆分投稿器文件,增加可读性
Signed-off-by: gfdgd_xi <3025613752@qq.com>
2026-06-22 14:04:28 +08:00
gfdgd-xi 8b00334dc8 feat: 投稿器支持导出tar包
Signed-off-by: gfdgd_xi <3025613752@qq.com>
2026-06-22 13:47:30 +08:00
shenmo7192andGitee 274e086bb1 !397 支持使用debian脚本构建deb包、新增投稿器(初稿)
Merge pull request !397 from gfdgd xi/Erotica
2026-06-18 16:40:45 +00:00
shenmo7192andGitee 341d87cd8d !399 add nixos support and update readme
Merge pull request !399 from SunnyPai/Erotica
2026-06-18 02:45:38 +00:00
gfdgd-xi 337c7b8200 修复因表单错误导致无法正常审核的问题
Signed-off-by: gfdgd_xi <3025613752@qq.com>
2026-06-17 17:28:27 +08:00
sunnypai 399b59dae8 fix pkexec lookup for privilege escalation 2026-06-17 13:30:38 +08:00
sunnypai 23b09ca863 update readme.md 2026-06-17 12:59:11 +08:00
sunnypai 601d3f51f4 add nixos support 2026-06-17 12:51:44 +08:00
gfdgd-xi cd3e087cdf 修复投稿器加载多个异常程序截图的问题
Signed-off-by: gfdgd_xi <3025613752@qq.com>
2026-06-17 11:21:38 +08:00
gfdgd-xi bd8d070f32 Bump version 5.2.0~alpha1
Signed-off-by: gfdgd_xi <3025613752@qq.com>
2026-06-16 22:43:28 +08:00
gfdgd-xi 3d964c9473 使用debian脚本构建deb包
Signed-off-by: gfdgd_xi <3025613752@qq.com>
2026-06-16 22:29:05 +08:00
gfdgd-xi 3aa96f27c7 新增投稿器
Signed-off-by: gfdgd_xi <3025613752@qq.com>
2026-06-16 11:53:46 +08:00
shenmo7192 3847463b6e aptss pt_BR fix 2026-06-13 19:31:13 +08:00
shenmo7192 8e7c6bc67d add pt_BR trans 2026-06-13 19:20:21 +08:00
shenmo7192andGitee c3ea2ddf1b !396 !1 调整Lists 区域标题在dark模式下的字体颜色,使其和欢迎语颜色一致,增加下载队列切换展开按钮在dark下的hover效果
Merge pull request !396 from shenmo/Erotica
2026-06-13 11:05:49 +00:00
shenmo7192andGitee 21e8a2ca3e !1 调整Lists 区域标题在dark模式下的字体颜色,使其和欢迎语颜色一致,增加下载队列切换展开按钮在dark下的hover效果
Merge pull request !1 from zeqi/Erotica
2026-06-10 12:44:34 +00:00
shenmo7192andGitee e7b90e5727 !395 新增 spark-store 支持处理 apt://包名 格式的链接,转成 spk://search/包名协议来处理
Merge pull request !395 from 球球代码研发助手/implement-33298693-3R1t
2026-06-10 12:13:44 +00:00
gitee-bot 9e8758b5f2 feat(deeplink): add support for apt:// protocol handling
- Register apt protocol handler in Electron main process
- Add x-scheme-handler/apt MIME type to electron-builder config
- Update desktop entry to include apt MIME type support
- apt://pkgname links are now converted to spk://search/pkgname

refs #IJTPFP
2026-06-10 11:50:21 +00:00
shenmo7192andGitee e84c1d86bf !394 新增 spark-store 支持处理 apt://包名 格式的链接,转成 spk://search/包名协议来处理
Merge pull request !394 from 球球代码研发助手/implement-33298693-hy0X
2026-06-10 11:46:22 +00:00
gitee-bot e39525901e feat(deeplink): add support for apt:// protocol conversion to spk://search
Add handling for apt://pkgname format links by converting them to
spk://search/pkgname protocol, allowing spark-store to process
apt package links seamlessly

refs #IJTPFP
2026-06-10 11:36:31 +00:00
zeqiandGitee f7eeddf6d9 调整Lists 区域标题在dark模式下的字体颜色,使其和欢迎语颜色一致,增加下载队列切换展开按钮在dark下的hover效果
Signed-off-by: zeqi <a202128502@163.com>
2026-06-06 14:05:54 +00:00
shenmo7192 24d55d0997 refactor: 优化代码结构并新增多标签分类应用加载逻辑
1. 重构下载重试超时列表为多行格式提升可读性
2. 合并环境变量配置行简化代码
3. 新增多标签分类页面的应用加载能力,包括:
   - 添加displayCategories和displayApps计算属性
   - 实现loadTabCategories和loadTabApps加载子分类数据
   - 抽离normalizeAppJson复用应用数据格式化逻辑
   - 优化侧边栏分类切换的应用过滤逻辑
2026-05-30 20:41:59 +08:00
momen 439af8c26f feat(account): polish reviews favorites and account UI 2026-05-29 21:34:42 +08:00
shenmo7192andGitee 8e8617218a !391 增加对aosc os的支持
Merge pull request !391 from Melorise/add-support-for-aosc
2026-05-22 15:06:40 +00:00
Melorise 97f49201b7 增加对aosc os的支持 2026-05-20 10:31:49 +08:00
momen abeb511c06 docs(ui): plan client polish fixes 2026-05-20 09:55:37 +08:00
momen 8d80a02316 fix(account): polish sidebar favorites and sync feedback 2026-05-19 23:48:41 +08:00
momen 932e69fca7 fix(account): configure production backend endpoint 2026-05-19 21:19:57 +08:00
momen 87d0cdc036 fix(favorites): keep folder selector above detail modal 2026-05-19 19:29:19 +08:00
momen 1de42a261a feat(reviews): show reviewer avatars 2026-05-19 16:30:41 +08:00
momen d03c8aab61 fix(ui): pin detail modal sidebar 2026-05-19 16:04:47 +08:00
momen ceb861a5b7 docs(ui): plan fixed detail sidebar scroll 2026-05-19 15:59:01 +08:00
momen ad831ce146 docs(ui): specify fixed detail sidebar scroll 2026-05-19 15:57:48 +08:00
momen a341071a3e docs(account): add reviews sync planning docs 2026-05-19 15:09:46 +08:00
momen 0895eb5049 test(reviews): cover server submit failures 2026-05-19 14:49:13 +08:00
momen fd17fc127d fix(reviews): restore modal detail review gating 2026-05-19 12:22:52 +08:00
momen 04b0ca061b merge: account collections implementation 2026-05-19 10:51:17 +08:00
momen deff1c20c4 fix(auth): clarify flarum login failures 2026-05-19 10:50:42 +08:00
momen a8a00d8165 fix(account): gate reviews and stale refreshes 2026-05-19 08:16:57 +08:00
momen 341c740ced test(sync): assert stale apps never upload 2026-05-19 02:20:48 +08:00
momen 839f4017f8 fix(sync): guard stale installed refreshes 2026-05-19 02:16:39 +08:00
momen 34551fce7b fix(sync): isolate installed sync state 2026-05-19 02:03:29 +08:00
momen 753f91e837 fix(sync): resolve restore item edge cases 2026-05-19 01:50:34 +08:00
momen acffb6c5ee feat(sync): add installed app cloud sync 2026-05-19 01:43:28 +08:00
momen ac1f46bd73 fix(account): ignore stale downloaded history 2026-05-19 01:33:34 +08:00
momen bbd9cbccb7 feat(account): add user management view 2026-05-19 01:17:58 +08:00
momen f280039874 fix(account): guard download record user races 2026-05-19 01:10:17 +08:00
momen b839e0770c fix(account): bind pending downloads to user 2026-05-19 01:02:53 +08:00
momen 4b81869b6e fix(account): keep download record pending through retry 2026-05-19 00:54:36 +08:00
momen 4c2225290c fix(account): record downloads after success 2026-05-19 00:44:36 +08:00
momen 78a04fb51f feat(account): record downloads and show reviews 2026-05-19 00:25:57 +08:00
momen 8da044495a fix(favorites): ignore stale account requests 2026-05-19 00:18:01 +08:00
momen 3a4aa7807a fix(favorites): clear account data on logout 2026-05-19 00:07:26 +08:00
momen 3a8baf606c fix(favorites): refresh installed apps across origins 2026-05-18 23:53:44 +08:00
momen 58789ecd1f fix(favorites): honor source priority and installed state 2026-05-18 23:40:28 +08:00
momen e116dcee63 feat(favorites): add cloud favorite management 2026-05-18 23:27:56 +08:00
momen 75df598bc0 fix(detail): normalize review store arch 2026-05-18 23:14:10 +08:00
momen e607e4991b feat(detail): move app details into content view 2026-05-18 23:08:29 +08:00
momen c2e8b9a1b4 fix(account): route forum login through ipc 2026-05-18 22:55:21 +08:00
momen 62081fb0ad fix(account): trim forum login password 2026-05-18 22:39:00 +08:00
momen 63dac217c2 feat(account): add forum login and sidebar account entry 2026-05-18 22:34:14 +08:00
momen c24c88458c fix(account): default backend api base url 2026-05-18 22:21:18 +08:00
momen 881aceb78c feat(account): add client account api foundation 2026-05-18 22:02:39 +08:00
momen 960bababc5 docs(account): add account collections implementation plans 2026-05-18 19:53:16 +08:00
momen 82d097330a docs(account): specify account collections client design 2026-05-18 19:24:21 +08:00
shenmo7192 c877f0551e feat: 新增动态侧边栏配置功能,优化主题色与侧边栏样式
新增SidebarEntry类型定义与侧边栏配置加载逻辑,支持从服务器拉取sidebar-config.json动态配置侧边栏入口
替换原分类侧边栏为可配置样式,新增CategoryBar分类选择组件,更新品牌色为苹果风格蓝色
重构侧边栏状态管理,拆分activeTab与选中分类逻辑,新增侧边栏入口计数统计
添加SIDEBAR_CONFIG.md文档说明配置格式与使用方法,更新测试用例与组件props
2026-05-18 13:25:52 +08:00
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
shenmo7192andGitee 8c8b53fc29 update tool/apt-fast/ss-apt-fast.
Signed-off-by: shenmo <jifengshenmo@outlook.com>
2026-05-09 15:04:46 +00:00
shenmo7192andGitee c50655c106 !389 dark下的效果还是怪怪的重新改一下,还有统一应用管理和软件更新的按钮样式
Merge pull request !389 from zeqi/Erotica
2026-05-03 04:02:34 +00:00
zeqiandGitee 4b37aa4da4 dark下的效果还是怪怪的重新改一下,还有统一应用管理和软件更新的按钮样式
Signed-off-by: zeqi <a202128502@163.com>
2026-05-03 02:35:28 +00:00
shenmo7192andGitee 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
shenmo7192andGitee 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
shenmo7192andGitee 994dbaf9b9 !387 优化dark模式下应用管理的关闭按钮和刷新按钮、软件更新的关闭按钮的hover效果
Merge pull request !387 from zeqi/Erotica
2026-04-16 22:59:56 +00:00
zeqiandGitee 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
xiyidaiwaandshenmo7192 bd8b50677e !385 !1 refactor(AppHeader): 优化搜索输入框交互及样式
Merge pull request !385 from xiyidaiwa/Erotica
2026-04-15 02:45:07 +00:00
shenmo7192andGitee 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
229 changed files with 48362 additions and 7310 deletions
+1
View File
@@ -1,3 +1,4 @@
VITE_APM_STORE_LOCAL_MODE=true
VITE_APM_STORE_BASE_URL=/local_amd64-store
VITE_APM_STORE_STATS_BASE_URL=/local_stats
VITE_SPARK_BACKEND_BASE_URL=http://127.0.0.1:8000
+2 -1
View File
@@ -1,2 +1,3 @@
VITE_APM_STORE_BASE_URL=https://erotica.spark-app.store
VITE_APM_STORE_BASE_URL=https://d.spark-app.store
VITE_APM_STORE_STATS_BASE_URL=https://feedback.spark-app.store
VITE_SPARK_BACKEND_BASE_URL=https://account.spark-app.store
-143
View File
@@ -1,143 +0,0 @@
name: Build
on:
push:
branches: [main]
tags:
- "*"
paths-ignore:
- "**.md"
- "**.spec.js"
- ".idea"
- ".vscode"
- ".dockerignore"
- "Dockerfile"
- ".gitignore"
- ".github/**"
- "!.github/workflows/build.yml"
- "!.github/workflows/test.yml"
pull_request:
branches: [main]
paths-ignore:
- "**.md"
- "**.spec.js"
- ".idea"
- ".vscode"
- ".dockerignore"
- "Dockerfile"
- ".gitignore"
- ".github/**"
- "!.github/workflows/build.yml"
permissions:
contents: write
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 20
- name: Install dependencies
run: npm install
- name: Run tests
run: npm run test
- name: Run lint
run: npm run lint
build:
needs: test
runs-on: ${{ matrix.os }}
container: ${{ matrix.docker_image }}
strategy:
matrix:
os: [ubuntu-latest]
package: [deb, rpm]
architecture: [x64, arm64]
include:
- package: deb
docker_image: "debian:12"
- package: rpm
docker_image: "almalinux:8"
steps:
- name: Install Build Dependencies
if: matrix.package == 'deb'
run: |
apt-get update
apt-get install -y curl git wget devscripts fakeroot equivs lintian python3
apt-get install -y build-essential
- name: Install Build Dependencies
if: matrix.package == 'rpm'
run: |
dnf install -y curl git wget rpm-build rpmdevtools rpmlint python3
dnf group install -y "Development Tools"
- name: Checkout Code
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 20
- name: Install Dependencies
run: |
npm install
- name: Download host-spawn
shell: bash
run: |
if [ "${{ matrix.architecture }}" == "x64" ]; then
curl -fsSL -o ./extras/host-spawn https://github.com/1player/host-spawn/releases/latest/download/host-spawn-x86_64
elif [ "${{ matrix.architecture }}" == "arm64" ]; then
curl -fsSL -o ./extras/host-spawn https://github.com/1player/host-spawn/releases/latest/download/host-spawn-aarch64
fi
chmod +x ./extras/host-spawn
- name: Build Release Files
shell: bash
run: |
if [ "${{ matrix.package }}" == "deb" ]; then
npm run build:deb -- --${{ matrix.architecture }}
elif [ "${{ matrix.package }}" == "rpm" ]; then
npm run build:rpm -- --${{ matrix.architecture }}
fi
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload Artifact
uses: actions/upload-artifact@v6
with:
name: release_for_${{ matrix.package }}_${{ matrix.architecture }}
path: release/**/*.${{ matrix.package }}
retention-days: 5
release:
needs: build
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/')
steps:
- name: Download all artifacts
uses: actions/download-artifact@v7
with:
path: artifacts
- name: Upload to GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
files: |
artifacts/**/*.deb
artifacts/**/*.rpm
generate_release_notes: true
-83
View File
@@ -1,83 +0,0 @@
name: Test
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 20
- name: Install dependencies
run: npm install
- name: Run unit tests
run: npm run test -- --coverage
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
files: ./coverage/lcov.info
flags: unittests
name: codecov-umbrella
e2e-tests:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 20
- name: Install dependencies
run: npm install
- name: Install Playwright Browsers
run: npx playwright install --with-deps chromium
- name: Run E2E tests
run: xvfb-run npm run test:e2e
- name: Upload test results
if: always()
uses: actions/upload-artifact@v6
with:
name: playwright-report
path: playwright-report/
retention-days: 30
lint:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 20
- name: Install dependencies
run: npm install
- name: Run ESLint
run: npm run lint
- name: Check formatting
run: npm run format -- --check
+28
View File
@@ -14,6 +14,17 @@ dist-electron
release
*.local
# Nix build outputs
/result
/result-*
# Local secrets and databases
.env
.env.*.local
*.sqlite
*.sqlite3
*.db
# Test coverage
coverage
.nyc_output
@@ -40,3 +51,20 @@ yarn.lock
test-results.json
.worktrees/
.superpowers/
# VSCode CMake Extension
build/*
# Debian build intermediates
debian/*.log
debian/*.substvars
debian/*.debhelper
debian/debhelper-build-stamp
debian/files
debian/tmp/
debian/.debhelper/
debian/spark-store/
# AI working memory (跨会话上下文,不进入上游)
.codebuddy/
+1
View File
@@ -30,6 +30,7 @@
// },
"runtimeArgs": [
"--remote-debugging-port=9229",
"--no-sandbox",
"."
],
"envFile": "${workspaceFolder}/.vscode/.debug.env",
+17
View File
@@ -695,6 +695,23 @@ npm run build:rpm # 仅构建 RPM 包
- `dist/` - 编译的渲染器资源
- 打包的应用在项目根目录
### 测试打包脚本(开发过程统一使用)
开发 / 自测阶段的打包**一律使用测试脚本 `scripts/test-build.sh`**,不要直接手敲 `dpkg-buildpackage`
```bash
./scripts/test-build.sh
```
脚本行为:
- 自动读取 `debian/changelog` 顶部的 deb 版本号(如 `5.2.1.0`),将**最后一段数字 +1** 并追加 `-test` 修订号(如 `5.2.1.1-test`)。
- 每次运行版本递增(`5.2.1.1-test` → `5.2.1.2-test` → …),产物形如 `spark-store_5.2.1.X-test_amd64.deb`,与正式发版版本区分。
- 自动设置 `ELECTRON_MIRROR` 镜像(`https://registry.npmmirror.com/-/binary/electron/`)后执行 `dpkg-buildpackage -us -uc -b`。
- 仅修改 `debian/changelog`,不改 `package.json`electron-builder 的 dir 产物版本由 `package.json` 决定,不影响 deb 文件名)。
> 注意:每次运行会改写 `debian/changelog`(未跟踪的工作区改动),测试完成后按需自行还原为正式版本。
### 构建配置
**electron-builder.yml:**
+21
View File
@@ -1,3 +1,24 @@
## [未发布 / Unreleased] (Erotica 分支,基于 1e60a4c2 之后)
本批改动汇总(自提交 `1e60a4c2` 起):
1. **已安装应用新增搜索框 + 窗口尺寸/位置持久化**:补齐已安装面板的应用搜索框;主窗口大小、位置、最大化状态写入本地 `window-state.json` 持久化,并限制最小尺寸 800×500。
2. **已安装列表健壮性加固**:修复异步竞态、错误 UX、加载状态处理与刷新防抖(多轮 AI 审查改进),并清理对应代码缩进与对齐。
3. **各页面滚动条贴边修复**:避免滚动栏被窗口圆角裁切。
4. **投稿应用窗口圆角虚影修复**:消除投稿弹窗圆角处的方角残影。
5. **已安装应用来源筛选与排序优化**:将头部 APM / Spark / 总数三段式统计徽章改为可点击,点击后按来源筛选(默认显示全部);列表默认 APM 应用置顶;新增"暂无已安装的 APM/Spark 应用"空状态提示,避免筛选无结果时出现空引号。
6. **AI 代码审查安全加固**
- `launch-app` IPC 移除 `any`,新增包名正则校验(`/^[a-zA-Z0-9._+-]+$/`),拦截非法输入防止命令注入。
- `getIconUrl` 本地图标路径增加白名单目录校验与路径遍历(`..`)防护。
- `fetchWithRetry` 仅对网络错误 / 5xx / 超时重试,4xx 快速失败。
- `AppGrid` 普通网格 `v-for``:key``index` 改为 `app.pkgname`
- `loong64` 架构下尊重 `--no-spark` 启动参数,不再硬编码覆盖。
- `openDownloadedApp` 的 IPC 调用补充 `.catch` 错误处理。
7. **应用详情默认展开已安装版本**:打开应用详情时,若本地只安装了 Spark / APM 中的某一版本,则默认展开该已装来源(例如 WeChat 默认 APM,但本机只装 Spark 版时默认展开 Spark);若两版都已安装,则按优先级/标签策略展示。
相关提交:`1d9d25d6``c75938aa``39a358bb``51473cac``cf0e72ff``b0d60737``d3705ed4`
## [1.1.1](https://github.com/elysia-best/apm-app-store/compare/v1.1.0...v1.1.1) (2026-02-17)
+20
View File
@@ -23,3 +23,23 @@
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
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.
+50
View File
@@ -0,0 +1,50 @@
.DEFAULT_GOAL := build
clean:
rm -rf release/
rm -f lightningcss-linux-loong64-gnu.tgz
rm -f tailwindcss-oxide-linux-loong64-gnu.tgz
build:
ifeq (${DEB_HOST_ARCH},loong64)
# Install oxide for loongarch64
curl -fsSL https://github.com/loong64/tailwindcss/releases/download/v4.3.2/tailwindcss-oxide-linux-loong64-gnu.tgz -o tailwindcss-oxide-linux-loong64-gnu.tgz
mkdir -p $(CURDIR)/node_modules/@tailwindcss/oxide-linux-loong64-gnu
tar xf tailwindcss-oxide-linux-loong64-gnu.tgz -C $(CURDIR)/node_modules/@tailwindcss/oxide-linux-loong64-gnu --strip-components 1
rm tailwindcss-oxide-linux-loong64-gnu.tgz
# Install lightningcss-linux-loong64-gnu for loongarch64
curl -fsSL https://github.com/loong64/lightningcss/releases/download/v1.32.0/lightningcss-linux-loong64-gnu-1.32.0.tgz -o lightningcss-linux-loong64-gnu.tgz
mkdir -p $(CURDIR)/node_modules/lightningcss-linux-loong64-gnu
tar xf lightningcss-linux-loong64-gnu.tgz -C $(CURDIR)/node_modules/lightningcss-linux-loong64-gnu --strip-components 1
rm lightningcss-linux-loong64-gnu.tgz
npm run build:loong64
else
npm run build
endif
install:
mkdir -p $(DESTDIR)/opt/spark-store/bin/
mkdir -p $(DESTDIR)/opt/spark-store/extras/
mkdir -p $(DESTDIR)/opt/durapps/spark-store/bin/
mkdir -p $(DESTDIR)/usr/share/icons/
mkdir -p $(DESTDIR)/usr/lib/
mkdir -p $(DESTDIR)/usr/bin/
mkdir -p $(DESTDIR)/etc/apt/
mkdir -p $(DESTDIR)/lib/systemd/
mkdir -p $(DESTDIR)/tmp/
cp -rv release/*/linux*-unpacked/* $(DESTDIR)/opt/spark-store/bin/
cp -rv release/*/linux*-unpacked/extras/* $(DESTDIR)/opt/spark-store/extras/
cp -rv tool/* $(DESTDIR)/opt/durapps/spark-store/bin/
cp -rv pkg/usr/share/fish/ $(DESTDIR)/usr/share/
cp -rv icons/hicolor/ $(DESTDIR)/usr/share/icons/
cp -rv pkg/usr/share/icons/hicolor/ $(DESTDIR)/usr/share/icons/
cp -rv pkg/usr/lib/systemd $(DESTDIR)/usr/lib/
cp -rv pkg/usr/share/applications/ $(DESTDIR)/usr/share/
cp -rv pkg/usr/share/polkit-1 $(DESTDIR)/usr/share/
cp -rv pkg/usr/share/aptss $(DESTDIR)/usr/share/
cp -rv pkg/usr/share/ssinstall/ $(DESTDIR)/usr/share/
cp -rv pkg/usr/share/ssinstall-local/ $(DESTDIR)/usr/share/
cp -rv pkg/usr/share/dsg/ $(DESTDIR)/usr/share/
cp -rv pkg/usr/share/bash-completion/ $(DESTDIR)/usr/share/
cp -rv tool/spark-store.asc $(DESTDIR)/opt/durapps/spark-store/bin/
# ln -s ../../../spark-store/extras/spark-store $(DESTDIR)/opt/durapps/spark-store/bin/spark-store
+1 -1
View File
@@ -18,7 +18,7 @@ Linux 应用的数量相对有限,Wine 软件的可获取性也颇为困难。
**当前支持的 Linux 发行版包括(但不限于):**
- **amd64 架构:** Debian 10+ / Ubuntu 22.04+ / Arch Linux / Fedora / deepin / UOS / 银河麒麟
- **amd64 架构:** Debian 10+ / Ubuntu 22.04+ / Arch Linux / Fedora / deepin / UOS / 银河麒麟 / NixOS
- **arm64 架构:** Debian 10+ / Ubuntu 22.04+ / Arch Linux / deepin / UOS / 银河麒麟
- **loong64 架构:** deepin 23/25
+84
View File
@@ -0,0 +1,84 @@
# 侧边栏入口配置 (Sidebar Config)
星火应用商店支持通过服务器上的 JSON 文件动态配置左侧侧边栏的入口项。
## 配置文件位置
`sidebar-config.json` 放置在服务器应用仓库的架构目录下:
```
# Spark 仓库
{baseUrl}/{arch}-store/sidebar-config.json
# APM 仓库
{baseUrl}/{arch}-apm/sidebar-config.json
```
例如:
- `https://example.com/amd64-store/sidebar-config.json`
- `https://example.com/arm64-store/sidebar-config.json`
## JSON 格式
每个入口项为一个对象,包含以下字段:
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `id` | string | ✅ | 唯一标识符,对应分类名或自定义 ID |
| `name` | string | ✅ | 侧边栏显示的入口名称 |
| `icon` | string | ❌ | FontAwesome 图标类名,如 `fas fa-gamepad` |
| `type` | string | ❌ | 入口类型:`category`(分类筛选)、`search`(搜索关键词)、`link`(外部链接)。默认为 `category` |
| `value` | string | ❌ | 与 `type` 配合使用的值。`category` 类型为分类名,`search` 类型为搜索关键词。默认为 `id` 的值 |
## 示例配置
```json
[
{
"id": "games",
"name": "游戏专区",
"icon": "fas fa-gamepad",
"type": "category",
"value": "games"
},
{
"id": "devtools",
"name": "开发工具",
"icon": "fas fa-code",
"type": "category",
"value": "development"
},
{
"id": "office",
"name": "办公学习",
"icon": "fas fa-book",
"type": "category",
"value": "office"
},
{
"id": "ai-search",
"name": "AI 应用",
"icon": "fas fa-robot",
"type": "search",
"value": "AI"
}
]
```
## 入口类型说明
### `category` 类型
点击后在"全部应用"页面按指定分类筛选应用。`value` 字段对应 `categories.json` 中的分类键名。
### `search` 类型
点击后自动使用 `value` 字段的值进行搜索。适用于快速入口,如"AI 应用"、"微信"等热门关键词。
### `link` 类型(预留)
用于跳转到外部链接或内部页面。后续版本支持。
## 注意事项
- 如果两个仓库(Spark 和 APM)都存在 `sidebar-config.json`,相同的 `id` 会自动去重合并
- 配置文件不存在时,侧边栏不会显示额外的入口项,不影响正常使用
- 入口项显示在"首页推荐"和"全部应用"之间,以分隔线区分
- 每个入口项会显示对应分类或搜索下的应用数量
+5
View File
@@ -0,0 +1,5 @@
spark-store (5.3.0.2) UNRELEASED; urgency=medium
* Initial release.
-- shenmo <shenmo@spark-app.store> Fri, 14 Aug 2026 15:14:01 +0800
+39
View File
@@ -0,0 +1,39 @@
Source: spark-store
Section: utils
Priority: optional
Maintainer: shenmo <shenmo@spark-app.store>
Rules-Requires-Root: no
Build-Depends:
debhelper-compat (= 13),
make,
Standards-Version: 4.7.2
Homepage: https://www.spark-app.store/
Package: spark-store
Architecture: any
Provides: spark-store-console-in-container
Depends:
${shlibs:Depends},
${misc:Depends},
libgtk-3-0,
libnotify4,
libnss3,
libxss1,
libxtst6,
xdg-utils,
libatspi2.0-0,
libuuid1,
libsecret-1-0,
xdg-utils,
shared-mime-info,
aria2,
gnupg,
zenity,
policykit-1 | pkexec,
libnotify-bin,
desktop-file-utils,
lsb-release,
systemd,
curl
Description: Spark Store
A community powered app store, powered by APM.
Vendored Executable
+81
View File
@@ -0,0 +1,81 @@
#!/bin/bash
case "$1" in
configure)
case `arch` in
x86_64)
echo "Enabling i386 arch..."
dpkg --add-architecture i386
;;
aarch64)
echo "Will not enable armhf since 4271"
;;
loongarch64)
echo "Nope we DO NOT WANT ABI1 now"
dpkg --remove-architecture loongarch64
;;
*)
echo "Unknown architecture, skip enable 32-bit arch"
;;
esac
mkdir -p /var/lib/aptss/lists
# Remove the sources.list file
rm -f /etc/apt/sources.list.d/sparkstore.list
rm -f /opt/durapps/spark-store/bin/apt-fast-conf/sources.list.d/sparkstore.list
# Check if /usr/local/bin existed
mkdir -p /usr/local/bin
## I hate /usr/local/bin. We will abandon them later
# Create symbol links for binary files
ln -s -f /opt/durapps/spark-store/bin/spark-store /usr/local/bin/spark-store
ln -s -f /opt/durapps/spark-store/bin/ssinstall /usr/local/bin/ssinstall
ln -s -f /opt/durapps/spark-store/bin/ssaudit /usr/local/bin/ssaudit
ln -s -f /opt/durapps/spark-store/bin/ssinstall /usr/bin/ssinstall
ln -s -f /opt/durapps/spark-store/bin/ssaudit /usr/bin/ssaudit
ln -s -f /opt/durapps/spark-store/bin/spark-dstore-patch /usr/local/bin/spark-dstore-patch
ln -s -f /opt/durapps/spark-store/bin/spark-dstore-patch /usr/bin/spark-dstore-patch
ln -s -f /opt/durapps/spark-store/bin/aptss /usr/local/bin/ss-apt-fast
ln -s -f /opt/durapps/spark-store/bin/aptss /usr/bin/aptss
# Install key
mkdir -p /tmp/spark-store-install/
cp -f /opt/durapps/spark-store/bin/spark-store.asc /tmp/spark-store-install/spark-store.asc
gpg --dearmor /tmp/spark-store-install/spark-store.asc
cp -f /tmp/spark-store-install/spark-store.asc.gpg /etc/apt/trusted.gpg.d/spark-store.gpg
# Start upgrade detect service
systemctl daemon-reload
systemctl enable spark-update-notifier
systemctl start spark-update-notifier
# like apt-daily.timer
systemctl enable spark-update-notifier.timer
systemctl start spark-update-notifier.timer
# Update certain caches
cp -fv /opt/spark-store/extras/store.spark-app.spark-store.policy /usr/share/polkit-1/actions/store.spark-app.spark-store.policy
xdg-mime default spark-store.desktop x-scheme-handler/spk
update-mime-database /usr/share/mime || true
# Send email for statistics
#/tmp/spark-store-install/feedback.sh
# Remove temp dir
rm -rf /tmp/spark-store-install
;;
triggered)
spark-dstore-patch
;;
esac
Vendored Executable
+7
View File
@@ -0,0 +1,7 @@
#!/bin/bash
rm -fv /usr/share/polkit-1/actions/store.spark-app.spark-store.policy
# Update certain caches
update-icon-caches /usr/share/icons/hicolor || true
update-desktop-database /usr/share/applications || true
update-mime-database /usr/share/mime || true
Vendored Executable
+28
View File
@@ -0,0 +1,28 @@
#!/bin/bash
#检测网络链接畅通
function network-check()
{
#超时时间
local timeout=15
#目标网站
local target=www.baidu.com
#获取响应状态码
local ret_code=`curl -I -s --connect-timeout ${timeout} ${target} -w %{http_code} | tail -n1`
if [ "x$ret_code" = "x200" ]; then
echo "Network Checked successful ! Continue..."
echo "网络通畅,继续安装"
else
#网络不畅通
echo "Network failed ! Cancel the installation"
echo "网络不畅,终止安装"
exit -1
fi
}
#network-check
echo "不再检测网络"
Vendored Executable
+64
View File
@@ -0,0 +1,64 @@
#!/bin/bash
function notify-send()
{
# Detect the user using such display
local user=$(who | awk '{print $1}' | head -n 1)
# Detect the id of the user
local uid=$(id -u $user)
sudo -u $user DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$uid/bus notify-send "$@"
}
if [ "$1" = "remove" -o "$1" = "purge" ] ; then
echo "$1"
echo "卸载操作,进行配置清理"
# Remove residual symbol links
unlink /usr/local/bin/spark-store
unlink /usr/local/bin/ssinstall
unlink /usr/local/bin/ssaudit
unlink /usr/bin/ssinstall
unlink /usr/bin/ssaudit
unlink /usr/local/bin/spark-dstore-patch
unlink /usr/bin/spark-dstore-patch
unlink /usr/local/bin/ss-apt-fast
unlink /usr/bin/aptss
rm -rf /etc/aptss/
rm -rf /var/lib/aptss/
# Remove residual symbol links to stop upgrade detect
rm -f /etc/xdg/autostart/spark-update-notifier.desktop
# Remove config files
for username in `ls /home`
do
echo /home/$username
if [ -d /home/$username/.config/spark-union/spark-store ]
then
rm -rf /home/$username/.config/spark-union/spark-store
fi
done
# Shutdown services
systemctl stop spark-update-notifier
# Stop update detect service
systemctl disable spark-update-notifier
# Remove gpg key file
rm -f /etc/apt/trusted.gpg.d/spark-store.gpg
apt-key del '9D9A A859 F750 24B1 A1EC E16E 0E41 D354 A29A 440C' || true
else
if [ ! -z "`pidof spark-store`" ] ; then
echo "关闭已有 spark-store.."
notify-send "正在升级星火商店" "请在升级结束后重启星火商店" -i spark-store
killall spark-store
fi
fi
Vendored Executable
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/make -f
# See debhelper(7) (uncomment to enable).
# Output every command that modifies files on the build system.
#export DH_VERBOSE = 1
# See FEATURE AREAS in dpkg-buildflags(1).
#export DEB_BUILD_MAINT_OPTIONS = hardening=+all
# See ENVIRONMENT in dpkg-buildflags(1).
# Package maintainers to append CFLAGS.
#export DEB_CFLAGS_MAINT_APPEND = -Wall -pedantic
# Package maintainers to append LDFLAGS.
#export DEB_LDFLAGS_MAINT_APPEND = -Wl,--as-needed
%:
dh $@
override_dh_dwz:
true
override_dh_strip:
true
override_dh_shlibdeps:
true
# dh_make generated override targets.
# This is an example for Cmake (see <https://bugs.debian.org/641051>).
#override_dh_auto_configure:
# dh_auto_configure -- \
# -DCMAKE_LIBRARY_PATH=$(DEB_HOST_MULTIARCH)
+1
View File
@@ -0,0 +1 @@
3.0 (quilt)
Vendored Executable
+2
View File
@@ -0,0 +1,2 @@
interest-noawait /opt/apps
@@ -0,0 +1,2 @@
1、将标题栏变宽一点,现在加入了搜索栏,太窄了不好看
2、在贡献排行中,点击对应的贡献用户,可以查看用户下所有的应用(可能需要模糊查询)
+268
View File
@@ -0,0 +1,268 @@
# 应用列表缓存导致"新应用上架后搜不到"的根因分析与设计方案
> App List Cache Analysis & Design (spark-store)
> 整理日期:2026-08-11
> 背景分支:`Erotica`
> 文档涵盖:① 列表缓存根因与修复(C1/C2);② 下载日志断层修复(附加)
---
## 0. 现象(用户反馈)
- 服务端上新应用后,在商店内**搜不到**该应用。
- 直觉认为是 `~/.cache` 下的本地缓存导致,但实测并非如此。
- 进一步反馈:**重启应用也无效,只有"删除缓存"才行**——这指向缓存层在重启后仍返回陈旧数据。
---
## 1. 缓存层逐层排查
| 缓存层 | 是否存在 | 证据 | 是否导致本问题 |
|---|---|---|---|
| **① 内存(渲染进程 `apps` 数组)** | 是 | 搜索基于内存 `baseApps``App.vue:665`);`loadApps` 仅在 `onMounted` 调用一次(`App.vue:3447`) | 是(叠加因素):运行期不刷新,新应用不在内存 |
| **② 本地磁盘"应用列表"文件缓存** | **否** | 源码无 `writeFile(applist)``~/.config/spark-store/` 下无应用列表缓存文件 | 否(用户最初怀疑点,已排除) |
| **③ Chromium 网络磁盘缓存** | **是(关键)** | 渲染进程 `axios` 走 XHR,受 Chromium 网络栈影响;`~/.config/spark-store/Cache` 目录存在 | **是**:重启后请求命中该磁盘缓存,返回旧 `applist.json` |
| **④ CDN 边缘缓存** | **是(关键)** | 生产域名 `erotica.spark-app.store` 挂 CDN;静态 `applist.json` 被边缘缓存;代码未发 `no-cache` | **是**:即使穿透③,CDN 仍可能返回边缘陈旧副本 |
### 请求侧缓存控制现状(代码事实)
- `fetchWithRetry``App.vue:432`):`axiosInstance.get(url, { signal })`**无任何 `Cache-Control` 头**。
- `axiosInstance``App.vue:427`):仅 `baseURL` + `timeout`,无 headers。
- 主进程 `onBeforeSendHeaders``electron/main/index.ts:653`):只注入 `User-Agent`**未注入 `Cache-Control: no-cache`**。
- `loadApps``App.vue:3216`):`apps.value.push(...)` —— **append 模式、无去重、无重置**,重跑会累积重复。
- `loadTabApps``App.vue:3098`):`if (tabApps.value[entryId]) return;` —— **已加载分类强缓存,永不重拉**
- `loadCategories``App.vue:3430`):`categories.json` 仅启动时加载一次;若为"新分类"则永不加载其 `applist.json`
---
## 2. 根因结论(实测修正)
> **2026-08-11 实测修正**:曾假设"CDN 边缘主动缓存"curl 实测远程 `applist.json` 响应头为 `server: nginx` + `etag` + `last-modified`**无任何 `Cache-Control`/`Expires`/`Age`**。因此真因是 **nginx 未发缓存控制头 → Chromium 启发式缓存(heuristic caching**,而非 CDN 主动边缘缓存。
**主因(解释"重启无效、删除才行")—— Chromium 启发式缓存:**
> 对"无 Cache-Control 头"的响应,Chromium 按 `Last-Modified` 计算启发式 TTL ≈ `(now - LM)/10`。实测当前文件 `LM` 距今约 7.6h → TTL ≈ **46 分钟**。在此窗口内:
> - 重启应用 → 重新发请求 → Chromium 直接复用 `~/.config/spark-store/Cache` 旧副本(启发式视为"新鲜")→ 返回**旧 JSON** → 新应用搜不到。
> - 只有**删除 `~/.config/spark-store/Cache`** 强制失效,才可能拿到新内容。
> 这与"重启无效、删缓存才行"的现象**精确吻合**。
**叠加因素(解释"运行期搜不到"):**
> `loadApps` 仅在启动时加载一次、且 `loadTabApps` 对已加载分类强缓存,运行期内存数据不会自动更新。即便请求能穿透缓存,也需重启/手动触发才进入内存。
**次要因素(条件触发):**
> 若新应用落入 `categories.json` 尚未包含的**新分类**`loadApps` 遍历 `categories.value` 不会请求该分类,连重启都搜不到,需等 `categories.json` 同步。
> 说明:用户最初怀疑的"~/.cache 应用列表文件缓存"**不存在**;真因是 nginx 无 CC 头导致的 Chromium 启发式缓存(③层的精确机理),其陈旧表现与"文件缓存"一致,故被误判。
### 2.1 实测证据(curl / Node2026-08-11
- `curl -sI` 远程 `applist.json``HTTP/2 200``server: nginx``etag: "6a7a764e-2f9d8"``last-modified: Tue, 11 Aug 2026 01:09:34 GMT`**无 `cache-control`/`expires`/`age`**。
- Node 计算:LM 距今 ≈ 27572s → 启发式 TTL ≈ 2757s ≈ **46 分钟**(窗口内重启命中陈旧副本)。
- `curl -H "Cache-Control: no-cache"``200` 正常 → 客户端带 no-cache 即不再复用陈旧副本,证明 **C1 修复方向正确**
-`?_t=Date.now()` 的 URL 经 `new URL()` 解析合法(pathname 正确、search 为 `_t=...`)→ **C2 修复方向正确**
---
## 3. 设计方案(最终版 · 已整合 7 维审计)
> 设计原则:**精准根治 + 配套必补项齐全 + 不过度设计**。
> 改动分两类——**核心(绕过缓存层,根治)** 与 **辅助(主动刷新体验)**;其中辅助项含 5 个**必补配套**,否则功能/可靠性打折(详见第 4 节审计结论)。
### 3.1 核心(根治"重启/删除才有效")—— 绕过缓存层
> 目标:列表/分类请求**永远拿最新**,不依赖删除缓存目录。
- **C1 请求头禁用缓存**
- `fetchWithRetry` 对数据 JSON 请求加 `headers: { 'Cache-Control': 'no-cache', Pragma: 'no-cache' }`
- 主进程 `electron/main/index.ts:653``onBeforeSendHeaders` 中,**仅对 `applist.json` / `categories.json` / `priority-config.json` 路径**注入 `Cache-Control: no-cache`scope 严格过滤,绝不影响 `cdn.d.store...` 图标/截图加速)。双保险覆盖所有请求路径。
- **C2 URL 版本 bust(穿透 CDN 边缘缓存最稳手段)**
- 对上述数据请求 URL 追加 `?_t=${Date.now()}`,使每次 URL 不同,CDN 边缘与 Chromium 均无缓存可命中。
- 代价:每次穿透 CDN,但列表文件小、频率低(启动/聚焦/手动),可接受。
- **兼容性确认项**:上线前需在测试环境确认 `erotica.spark-app.store` 的 CDN 对含 query 的静态路径行为正常(极少数 CDN 有特殊规则);若有异常,回退为仅依赖 `no-cache` 或 path 版本戳。
### 3.2 辅助(主动刷新体验)—— 让用户无需重启即拿最新
> 前提:3.1 已绕过缓存层。否则刷新也拿不到新数据。
- **A1 `loadApps` 幂等重建(必做,刷新安全前提)**
-`App.vue:3216``apps.value.push` 改为:每个分类结果收集进 `Map<pkgname, App>`(复用现有 hybrid 合并逻辑),**该分类加载完即 `apps.value = [...map.values()]`**——保留增量提交(首个分类成功即关遮罩,不回退首屏体验),同时对所有模式(含 spark-only/apm-only)去重,反而比现状更稳。
- **A2 `refreshAppData()` 编排**
- 提取 `await loadCategories()`(本身重建安全,`App.vue:2946`+ 重跑改造后的 `loadApps()`。覆盖主因与次要因素(新分类一并刷新)。
- **A3 两个刷新入口**
- **窗口聚焦(经主进程,非渲染 window focus**:主进程 `win.on('focus')``webContents.send('window-focus')` → 渲染监听调用 `refreshAppData()`Electron 无边框窗口的渲染 `window focus` 事件不一定可靠,必须走 IPC 才稳)。
- **手动刷新按钮**:工具栏/侧边栏加刷新图标,调用 `refreshAppData()`
### 3.3 必补配套(5 项,缺一不可)
| 编号 | 维度 | 配套项 | 解决风险 |
|---|---|---|---|
| **P1** | 功能正确 | A1 改造时**保留每分类增量提交** + 非 hybrid 也按 pkgname 去重 | 防首屏回退、防重复应用 |
| **P2** | 可靠性 | `refreshAppData`/`loadApps` 加**串行化守卫**in-flight 合并或 `isRefreshing` 标志) | 防焦点/手动/启动并发竞态覆盖(`loadApps` 当前无锁,已确认) |
| **P3** | 可靠性 | 用**主进程 `win.on('focus')`→IPC** 而非渲染 `window focus` | 防无边框窗口 focus 事件不可靠导致刷新静默失效 |
| **P4** | 性能 | 聚焦刷新加**防抖(~3s)+ 节流** | 防切窗口抖动堆叠请求、穿透 CDN 放大流量 |
| **P5** | 可观测 | `refreshAppData` 入口打印 `[AppData] 触发刷新 (reason=focus\|manual)``applist` 加载后记录条目数变化(`1200 → 1201`) | 便于线上诊断"刷新是否真生效 / 新应用是否进内存" |
### 3.4 可选
- **O1 `loadTabApps` TTL 失效**`App.vue:3098` 强缓存改"超过 TTL(如 10min)才重拉"。因 all 搜索主路径已由 3.1+3.2 覆盖,可后置。
---
## 4. 7 维专业审计结论(改动影响预评估)
| 维度 | 影响评估 | 关键结论 |
|---|---|---|
| **1. 功能正确性** | 改善明确;A1/C2 改造需连带处理增量提交与去重(P1) | 根治"陈旧列表",但 C 改动需保增量体验 |
| **2. 性能** | C2 每次穿透 CDN,开销小;**唯一主要负向是聚焦频繁触发** | 需 P4 防抖;否则成主要性能负向 |
| **3. 安全性** | `no-cache`/时间戳 bust 中性;scope 必须限数据路径 | 不全局注入,不影响图标加速 |
| **4. 可靠性** | 两处真实风险:`loadApps` 无并发锁(竞态)、渲染 focus 不可靠 | 需 P2 串行化 + P3 IPC focus |
| **5. 兼容性** | `no-cache`/query 对所有 CDN/HTTP 通用;仅需一次 CDN 行为确认(C2) | 兼容性良好 |
| **6. 可维护性** | `refreshAppData` 抽出后逻辑集中、易测;增量代码小 | 维护性改善 |
| **7. 可观测/可测** | 当前刷新路径无专属日志;应补 P5 | 便于上线诊断 |
**总体**:方案根治有效,性能/安全负向可控;**3 个必补配套(P1/P2/P3+ 2 个强建议(P4/P5)** 须一并实现,否则功能正确性/可靠性打折。
---
## 5. 方案落地可行性验证(代码核对 · 测试,不改功能代码)
> 本节为在动手前对方案各项做的**代码级可行性核对**(未修改任何源码),确认可实现性与需细化点。
### 5.1 逐项核对结论
| 项 | 代码事实(已核对) | 可行性 | 需细化点 |
|---|---|---|---|
| **C1 主进程 no-cache 注入** | `onBeforeSendHeaders``electron/main/index.ts:653` 注册,可拿 `details.url`(完整 URL,含 `.../applist.json` 等)。用 `url.includes('/applist.json')\|\|includes('/categories.json')\|\|includes('/priority-config.json')` 过滤注入即可 | ✅ 可行 | scope 必须限三路径,不影响 `cdn.d.store...` 图标 |
| **C2 URL bust** | 数据请求三入口(`loadApps` `App.vue:3203``loadTabApps` `3133/3151``loadCategories` `2925`**均走 `fetchWithRetry`**;但 `loadPriorityConfig``storeConfig.ts:84`)走**独立 `priorityConfigAxios`,不经过 `fetchWithRetry`** | ✅ 可行 | **bust 须覆盖两个 axios 实例**;否则 `priority-config.json` 仍可能陈旧(影响 auto 策略,非搜索主路径但同源) |
| **A1 loadApps Map 幂等重建** | 当前 `App.vue:3216` `apps.value.push`,在 `Promise.all`(`3190`) 的 `category→origins` 异步循环内。改:函数内声明跨分类 `Map<pkgname,App>`,每分类 `origins` 完成后 `apps.value = [...map.values()]` | ✅ 可行 | 增量提交须保留在每个**分类**处理末尾(非等全部完成),否则首屏回退;Map 按 pkgname 作 key 天然覆盖 spark-only/apm-only 去重 |
| **A2 refreshAppData 编排** | 新函数 = `await loadCategories()` + 重跑改造后 `loadApps()`;与 `loadApps` 同作用域即可 | ✅ 可行 | 建议 `onMounted` 也改调 `refreshAppData` 而非裸 `loadApps`,统一入口 |
| **A3 + P3 主进程 focus→IPC** | 主进程 `win` 为模块级 `let win`(`electron/main/index.ts:122`)`createWindow``mainWindow`(`418`)。`mainWindow.on('focus', () => mainWindow.webContents.send('appdata-window-focus'))` 可靠(主进程事件,不受无边框影响) | ✅ 可行 | 渲染侧 `window.ipcRenderer.on('appdata-window-focus', ...)` 调用 `refreshAppData`**preload 无需改动**(通用 `on` 通道已暴露) |
| **P2 串行化守卫** | `loadApps` 当前**无并发锁**`loading.value` 仅遮罩),已确认 | ✅ 可行 | `refreshAppData` 内加 `refreshInFlight` Promise 合并:`if (inFlight) return inFlight; inFlight=(async()=>{...})().finally(()=>inFlight=null)` |
| **P4 防抖** | focus 抖动会快速多次触发 | ✅ 可行 | P2 的 in-flight 合并已天然防抖首跑;再叠加 time-based 节流(如 10s 内仅一次)更稳 |
| **P5 刷新日志** | 既有 Pino `logger`(如 `App.vue:3205` `logger.info('加载分类...')` | ✅ 可行 | `refreshAppData` 入口 `[AppData] 触发刷新 reason=...``loadApps` 完成 `[AppData] 应用数 X → Y`Y=map.size |
### 5.2 关键发现(影响方案完整性)
1. **C1 主进程注入是 C2 的兜底**:主进程 `onBeforeSendHeaders` 注入 `no-cache` 对**所有**经 Chromium 的请求(含 `priorityConfigAxios` 的 XHR)生效;即便 C2 漏给 `priorityConfigAxios` 加 bust,C1 也能保证其不读缓存。**建议 C1 为主、C2 为辅**,两者互补而非二选一。
2. **C2 必须显式覆盖 `priorityConfigAxios`**:因其独立于 `fetchWithRetry`,要在 `storeConfig.ts:84``priorityConfigAxios.get(configPath)` 处单独加 bust(或在 `priorityConfigAxios` 实例层加请求拦截器统一加 `?_t`)。否则 auto 策略配置可能陈旧。
3. **loadApps 异步嵌套**:当前 `categoriesList.map(async category => origins.map(async mode => ...))` 嵌套异步;A1 改造时,需把内层改为 `await Promise.all(origins.map(...))` 后在分类回调末尾提交 `apps.value`,确保"每分类增量提交"。
4. **下游无回归**`apps.value` 整体替换会触发 Vue 响应式,`baseApps`/`filteredApps`/排行均依赖它,赋值即刷新,无兼容问题。
5. **preload 零改动**:新 IPC 通道 `appdata-window-focus` 直接 `send`/`on`,复用已暴露的通用 `ipcRenderer`,不需改 `contextBridge`
### 5.3 测试结论
- **全部 8 个方案项(C1/C2/A1/A2/A3 + P1~P5)均可实现,无不可落地项。**
- 落地前须落实 5 个细化点(5.1 表"需细化点" + 5.2 关键发现),其中 **C2 双 axios 覆盖****C1 兜底关系** 是最易被遗漏、却影响完整性的两点。
- 风险等级:均为低~中,无高危阻塞;P2(竞态)与 A3 的 IPC 可靠性属"不做则功能打折"的必补项,已在方案中标明。
### 5.4 实际效果验证(2026-08-11,本机可执行部分已实测)
> 用户要求验证"实际效果"。区分两类验证:
> - **(a) 机理实证(本机已完成)**:用 curl/Node 验证根因与修复方向正确性,无需 GUI/服务端改动。
> - **(b) 端到端实证(需真机+服务端上架新应用,本机无法完成)**:见第 7 节,须由用户在真实环境执行。
**(a) 已完成的本机实证:**
1. `curl -sI` 远程 `applist.json`**无 `cache-control` 头、`server: nginx`** → 证实 Chromium 启发式缓存为真因(非 CDN 主动缓存)。
2. Node 计算启发式 TTL ≈ 46 分钟 → 量化"重启仍陈旧"的窗口。
3. `?_t=Date.now()` URL 经 `new URL()` 解析合法 → C2 方向正确。
4. `curl -H "Cache-Control: no-cache"` 仍 200 → 客户端带 no-cache 即绕过陈旧副本,C1 方向正确。
5. 串行化守卫 / Map 幂等去重 / focus→IPC 逻辑已在 5.1 核对,均为纯逻辑可单测(实现后补单测即闭环)。
**(b) 尚待真机验证(非本机能力范围):**
- 需在**服务端上架一个真实新应用**(最好落入已存在分类,隔离"新分类"变量)。
- 安装修复包后,**不删缓存、不重启** → 聚焦/刷新 → 搜到新应用(验证 C1/C2 绕过启发式缓存)。
- 重启不清缓存 → 首屏即显示(验证重启不再拿旧数据)。
- 对照旧包同样操作需删 `Cache` 才有效 → 根因闭合。
- 说明:本机为无显示环境(headless Linux),且无法操作远端商店后台上架,故 (b) 必须用户在真实环境执行;**但根因机理已由 (a) 实证闭合,修复方向确凿**。
---
## 6. 明确不做(避免过度设计)
-**定时轮询**(后台常驻网络/CPU 开销,用户未要求实时)。
-**本地磁盘离线缓存**(问题不在离线,反而引入陈旧风险)。
-**ETag / 304 协商缓存**(axios 不过系统缓存层,且已有 no-cache + bust 更稳;除非确认代理忽略请求头才需要)。
-**全局 `Cache-Control` 注入到所有请求**(仅对数据 JSON 路径注入,避免影响图标/截图等静态资源加速)。
---
## 6. 验证方法
> **验证分工**:步骤 1(单测)随实现完成;步骤 2–5(端到端)需服务端上架新应用 + 真实 GUI 环境,**由用户在真机执行**(本机 headless 无法完成)。
1. **单元/逻辑**`loadApps` 幂等化后补测——重跑两次 `apps.value` 长度不变、无重复 pkgname`refreshAppData` 串行化 + 防抖单测。
2. **集成(根因闭合)**:服务端上架新应用(落入已存在分类,隔离"新分类"变量)。
3. 安装修复包,**不删缓存、不重启** → 聚焦窗口 / 点刷新 → 应搜到新应用(证明缓存已绕过,P5 日志应显示条目数 +1)。
4. 重启应用(不清缓存)→ 首屏即显示新应用(证明 no-cache + bust 生效,重启不再拿旧数据)。
5. **对照旧包**:同样操作需"删除 `~/.config/spark-store/Cache` 才有效"——确认根因闭合、修复生效。
> 注:机理层面已由第 5.4 节本机 curl/Node 实证闭合(nginx 无 CC 头 → Chromium 启发式缓存 ≈46min;C1/C2 方向正确),故修复方向确凿,端到端仅作最终确认。
---
## 附:历次分析演进
- **第一轮**:怀疑 `~/.cache` 本地应用列表文件缓存 → 核查排除(无磁盘列表缓存,搜索基于内存)。
- **第二轮**:深入确认"运行期不刷新"为主因(`loadApps` 仅启动一次、append 无去重、`loadTabApps` 强缓存、categories 启动一次)。
- **第三轮**:结合"重启无效、删除才行"反馈,定位为 **Chromium 磁盘缓存 + CDN 边缘缓存**双重陈旧,且代码零缓存失效机制;修正上轮结论——重启无效的根因是缓存层喂旧数据,而非单纯内存不刷新。
- **第四轮(方案验证)**:按用户要求"先测试不改代码",对最终方案的 8 项(C1/C2/A1/A2/A3 + P1~P5)做代码级可行性核对,确认全部可落地;补充 5 个细化点(C2 须覆盖 `priorityConfigAxios` 独立实例、C1 主进程注入为兜底、loadApps 异步嵌套改造、下游无回归、preload 零改动),写入第 5 节。方案待用户审核后决定是否实现。
---
## 8. 附加修复:下载日志断层(下载卡在"正在获取 Metalink"突兀退出)
### 8.1 现象(用户反馈)
下载应用时日志停在:
```
[13:57:09] 开始下载...
[13:57:09] 正在获取 Metalink 文件: amd64-store/office/com.qianwen.otohime/com.qianwen.otohime_3.7.5.145_amd64.deb.metalink
```
随后界面无过渡直接结束(状态变 failed / 任务消失),中间无任何失败原因,体验突兀。
### 8.2 根因
下载流程在 `electron/main/backend/install-manager.ts``runDownloadPhase`
1. **Metalink 下载请求失败无前端日志**`await axios.get(...)` 抛错时,原代码未 `sendLog`,错误只在主进程 `logger.error` 静默记录;错误向上抛到外层 catch → 直接发 `install-complete {success:false}`,渲染端日志面板永远停在"正在获取 Metalink"。
2. **aria2c 拉起失败也无日志**`child.on("error")``reject(err)`,渲染端同样看不到原因。
3. **渲染端 `install-complete` 失败分支不写 logs**`src/modules/processInstall.ts` 收到失败时只改 `status="failed"`,未把 `log.message` 的失败原因写入 `downloadObj.logs`,导致日志卡在最后一条中间状态。
三者叠加:任一环节失败,UI 都在"正在获取 Metalink"后突兀结束且无原因。
### 8.3 修复(已提交)
- **F1(主进程 Metalink 失败日志)**`axios.get` 用 try/catch 包裹,失败时 `sendLog("获取 Metalink 失败: <reason>")``throw`,保证渲染端能显示原因。Metalink 写入 `finish` 回调补 `sendLog("Metalink 文件下载完成")`
- **F2aria2c 拉起失败日志)**`child.on("error")``sendLog("aria2c 启动失败: <msg>")`
- **F3(渲染端失败原因入日志)**:`install-complete``else` 分支解析 `log.message` 中的 `message` 字段,push 一条 `下载失败: <reason>``downloadObj.logs`;成功分支补 `下载完成`
改动文件:
- `electron/main/backend/install-manager.ts`F1、F2
- `src/modules/processInstall.ts`F3
> 注:本修复仅改善"失败可见性",不改变下载/重试/安装逻辑本身;UI 不再突兀卡在中间日志。
### 8.4 验证边界
本机 headless 无法真实触发 aria2c 下载与 GUI 日志面板;已通过 `vue-tsc` 类型检查与 `read_lints` 0 错误。真实失败场景(如 Metalink 404、网络中断)下的日志连贯性需用户在真机验证。
---
## 9. 附加修复:更新中心点击"更新"后卡"开始更新..."
### 9.1 现象(用户反馈)
在软件更新中心选择 Visual Studio Code: 点击更新后,下载详情弹窗日志停在:
```
[09:23:41] 开始更新...
```
状态为 `queued`,后续无进展、无错误,界面卡死。
### 9.2 根因
更新中心 `electron/main/backend/update-center/service.ts``start()` 方法,把任务通过 `webContents.send("queue-install", JSON.stringify(installTaskData))` 发送给主下载队列。
**但 `webContents.send` 是从主进程向渲染进程发消息,渲染进程的 `ipcRenderer.on("queue-install")` 能收到,而主进程的 `ipcMain.on("queue-install") 监听的是渲染进程 `ipcRenderer.send` 的消息,监听不到自己 `webContents.send` 的消息。**
结果:任务根本没有进入 `install-manager.ts` 的下载队列,`processNextDownload()` 永远不会执行,UI 自然卡在"开始更新...」。
### 9.3 修复(已提交)
- **I1(抽离可复用的入队函数)**:在 `electron/main/backend/install-manager.ts` 新增导出 `addInstallTask(payload, sender)`,把原来 `ipcMain.on("queue-install")` 里的解析、校验、去重、APM 检查、命令构建、入队逻辑全部抽到该函数。`ipcMain.on` 本身只做 JSON 解析并调用 `addInstallTask`
- **I2(更新中心直接调用入队)**:`electron/main/backend/update-center/service.ts``start()` 不再 `webContents.send("queue-install")`,而是直接 `await addInstallTask(installTaskData, webContents)`,使任务真正进入主下载队列。
- **I3(类型安全)**:新增 `QueueInstallPayload` 接口,避免 `any`
改动文件:
- `electron/main/backend/install-manager.ts`I1、I3
- `electron/main/backend/update-center/service.ts`I2
### 9.4 验证边界
本机 headless 无法启动 GUI 触发真实更新下载;已通过 `vue-tsc``read_lints` 0 错误。真机需在软件更新中心勾选一项更新并点击"更新",确认日志从"开始更新..."推进到"正在获取 Metalink 文件"、下载进度增加,最终进入安装或明确失败。
+162
View File
@@ -0,0 +1,162 @@
# 排行榜与星火荣耀榜 · 项目规划文档
> 状态:**草图设计已确认,待实现**
> 分支:`Erotica`
> 日期:2026-07-30
> 关联预览原型:`ranking-preview.html`(仓库根目录,仅用于设计预览,不入库)
---
## 1. 项目背景与目标
在「已安装应用」模块重构(来源标签化、统计徽章、搜索、来源筛选、APM 置顶)完成后,本次规划聚焦于**首页与发现页的内容重组**:
- 将原本散落在「首页推荐」中的**下载排行**独立为「排行榜」一级页面,并补充**应用更新排行**;
- 新增**「星火荣耀榜」**页面,表彰应用贡献者与近期更新贡献者;
- 精简「首页推荐」页:移除精选板块(区域2)与下载排行(区域3),仅保留精选链接(区域1),并在区域1 顶部加入致谢说明。
**预期成果**:形成「首页推荐(轻量入口)— 排行榜(下载/更新)— 荣耀榜(贡献者荣誉)」三层递进的发现体系,强化社区贡献者认同感。
---
## 2. 数据可行性核查(代码已确认)
| 字段 | 来源 | 结论 |
|------|------|------|
| `app.downloadCount` | 现有 `apmRanking` / `sparkRanking``App.vue` 11641250,异步逐应用拉 `download-times.txt`) | ✅ 直接复用,无需新增请求 |
| `app.update` | `App.vue:886``appJson.Update` 映射,格式 `"2026-01-26 17:34:15"`,同格式可直接 `localeCompare` 倒序 | ✅ 列表已含,可直接排序 |
| `app.contributor` | `App.vue:884``appJson.Contributor` 映射 | ✅ 列表已含,无需详情请求 |
> 结论:**荣耀榜/更新榜所需字段均已在列表加载阶段填充**,聚合逻辑可基于已加载的 `apps` 实时计算,不引入额外网络请求。
---
## 3. 任务总览(优先级 + 阶段)
| 任务ID | 名称 | 类型 | 优先级 | 阶段 | 关联模块 |
|--------|------|------|--------|------|----------|
| F1 | 排行榜页(RankingView | 新功能 | P0 | 待实现 | App.vue, AppSidebar, HomeView(移除区域3) |
| F1.1 | 应用下载排行(Spark/APM 双榜) | 新功能 | P0 | 待实现 | 复用 apmRanking/sparkRanking |
| F1.2 | 应用更新排行(Spark/APM 双榜) | 新功能 | P0 | 待实现 | appsupdate 字段) |
| F2 | 星火荣耀榜页(HonorView | 新功能 | P0 | 待实现 | App.vue, AppSidebar |
| F2.1 | 贡献荣耀榜(按来源聚合 contributor | 新功能 | P0 | 待实现 | appscontributor 字段) |
| F2.2 | 更新荣耀榜(最近更新应用 contributor 聚合) | 新功能 | P0 | 待实现 | appsupdate+contributor |
| F2.3 | 致谢说明卡片 | 新功能 | P1 | 待实现 | HonorView / HomeView 复用 |
| F3 | 侧边栏导航入口 | 新功能 | P0 | 待实现 | AppSidebar.vue, App.vue |
| M1 | 首页推荐页改造(去区域2/3,区域1加致谢) | 修改现有 | P0 | 待实现 | HomeView.vue |
| M2 | App.vue 视图路由与数据编排 | 修改现有 | P0 | 待实现 | App.vue |
| M3 | 榜单聚合工具函数 | 重构/提取 | P1 | 待优化 | 新增 util 或 App.vue 内 computed |
| O1 | 边加载边更新体验优化 | 优化 | P2 | 待优化 | HonorViewcomputed + watch |
| O2 | 榜单条数/来源可配置 | 优化 | P2 | 待优化 | RankingView/HonorView props |
| O3 | 空状态与加载失败兜底 | 优化 | P1 | 待优化 | RankingView/HonorView |
| R1 | 类型安全与 lint 修复 | 修复 | P1 | 待修复 | 全部新增组件 |
| R2 | 与现有列表加载/更新逻辑的一致性校验 | 修复 | P1 | 待修复 | App.vue, install-manager |
---
## 4. 详细任务分解
### 4.1 新功能模块
#### F1 · 排行榜页(RankingView.vue
**F1.1 应用下载排行**
- **改动描述**:新增 `RankingView` 组件,区块一展示「应用下载排行」,内部按来源分为 Spark 下载榜 / APM 下载榜两栏,各取下载量前 10 名。复用现有 `apmRanking` / `sparkRanking` 数据(由 `App.vue` 传入),列表项沿用 `AppCard``compact` 样式,并在左侧加排名序号(前 3 名用金/银/铜配色)。
- **预期目标**:与现有首页下载排行视觉一致,且独立成页后信息更聚焦。
- **关联模块**`App.vue`(传入 `apmRanking`/`sparkRanking`)、`AppCard.vue``AppSidebar.vue`(入口)。
- **备注**:当前首页区域3 的展示可直接迁移,避免重复实现。
**F1.2 应用更新排行**
- **改动描述**:区块二展示「应用更新排行」,按来源分为 Spark 更新榜 / APM 更新榜两栏,各取 `app.update` 倒序前 10 名;列表项复用 `AppCard` compact 样式,副信息显示版本与更新时间(替换下载量徽章)。
- **预期目标**:让用户快速了解近期活跃更新的应用。
- **关联模块**`App.vue``apps` 全量数据)、`AppCard.vue`
- **备注**:排序用 `update.localeCompare` 倒序(格式统一可行)。
#### F2 · 星火荣耀榜页(HonorView.vue
**F2.1 贡献荣耀榜**
- **改动描述**:区块一展示「贡献荣耀榜」,按来源分为 Spark / APM 两栏,各取 `app.contributor` 出现次数前 10 名(聚合基于已加载 `apps`,实时计算)。列表项显示排名、贡献者名(去除 `<email>` 后缀)、上榜应用数徽章。
- **预期目标**:表彰对星火生态贡献最多的开发者/维护者。
- **关联模块**`App.vue``apps`)、`HonorView.vue`
**F2.2 更新荣耀榜**
- **改动描述**:区块二展示「更新荣耀榜」,按来源分为 Spark / APM 两栏;取最近更新(按 `update` 倒序)的前 30 个应用,聚合其 `contributor` 出现次数,取前 10 名展示。
- **预期目标**:突出近期持续维护应用的贡献者。
- **关联模块**`App.vue``apps`)、`HonorView.vue`
- **备注**:与 F1.2 同源(最近更新应用列表),可共享计算。
**F2.3 致谢说明卡片**
- **改动描述**:在荣耀榜页顶部加入美化后的致谢卡片(渐变琥珀背景 + 图标 + 文案「致每一位星火贡献者…🎉」);同一卡片样式也用于首页推荐区域1(见 M1)。
- **预期目标**:强化社区归属感,视觉与整体风格统一。
- **关联模块**`HonorView.vue``HomeView.vue`(复用同一段模板/组件)。
#### F3 · 侧边栏导航入口
- **改动描述**:在 `AppSidebar.vue` 新增「排行榜」「荣耀榜」两个一级入口,点击分别 `selectTab('ranking')` / `selectTab('honor')`,激活态沿用 `.sidebar-tab-active` 样式。
- **预期目标**:用户可从侧边栏直达新页面。
- **关联模块**`AppSidebar.vue``App.vue``activeTab` 状态)。
### 4.2 需要修改的现有模块
#### M1 · 首页推荐页改造(HomeView.vue
- **改动描述**:移除模板中的**区域2·精选板块**(`recommendSections` 应用列表区块)与**区域3·下载排行**(`apmRanking`/`sparkRanking` 区块)及其对应 props`recommendSections``apmRanking``sparkRanking``rankingLoading`);在区域1(精选链接 `homelinks` 网格)顶部插入致谢说明卡片(F2.3 同款)。
- **预期目标**:首页回归轻量入口定位,下载排行/精选板块职责移交排行榜页与「全部应用」。
- **关联模块**`HomeView.vue``App.vue`(停止向 HomeView 传 ranking/recommend 数据,或保留 recommend 供「全部应用」使用——需确认 recommendSections 是否仍被其他入口复用)。
- **备注**:⚠️ 需确认 `recommendSections` 是否仅首页使用;若「全部应用」等也依赖,则不能简单删除数据加载。
#### M2 · App.vue 视图路由与数据编排
- **改动描述**:主内容区新增 `v-else-if="activeTab === 'ranking'"``<RankingView>``v-else-if="activeTab === 'honor'"``<HonorView>`;将现有 `apmRanking`/`sparkRanking` 改传 `RankingView`;荣耀榜所需的全量 `apps``computed` 形式实时传入 `HonorView`(支撑"边加载边更新")。
- **预期目标**:打通新页面路由与数据流。
- **关联模块**`App.vue``RankingView.vue``HonorView.vue`
#### M3 · 榜单聚合工具函数(待优化阶段)
- **改动描述**:将"按来源取 Top N""聚合 contributor 次数"等逻辑提取为独立 `util` 函数(如 `src/modules/ranking.ts`),供 RankingView/HonorView 复用,避免组件中重复实现。
- **预期目标**:提升可维护性、便于单元测试。
- **关联模块**:新增 `src/modules/ranking.ts`
---
## 5. 阶段划分与执行顺序
### 阶段一 · 待实现(P0,核心交付)
1. **M2** App.vue 路由骨架 + **F3** 侧边栏入口(打通页面切换)
2. **F1** RankingViewF1.1 下载榜 → F1.2 更新榜)
3. **F2** HonorViewF2.1 贡献榜 → F2.2 更新榜 → F2.3 致谢)
4. **M1** HomeView 首页改造(去区域2/3 + 区域1 致谢)
### 阶段二 · 待优化(P1–P2,体验打磨)
- **O3** 空状态/加载失败兜底(榜单为空、apps 未加载时友好提示)
- **M3 / O2** 聚合工具提取、榜单条数可配置(当前固定 10)
- **O1** 边加载边更新:用 `computed` + `watch(apps)` 实时刷新,进入荣耀榜即开始聚合,无需手动定时器
### 阶段三 · 待修复(P1,质量保障)
- **R1** 全部新增组件通过 `vue-tsc` 类型检查与 ESLint(严格模式,禁用 `any`
- **R2** 校验与现有列表加载(`loadCategories`/`refreshInstalledApps`)、更新逻辑的一致性,避免重复遍历导致的性能问题(全量 `apps` 聚合建议在 `computed` 中记忆化)
---
## 6. 风险与待确认项
1. **推荐数据归属**`recommendSections` 是否仅首页使用?若「全部应用」等入口复用,M1 删除区域2 时需保留数据加载逻辑,仅移除模板渲染。
2. **荣耀榜数据范围**:确认基于"已加载 `apps`"即可满足需求(用户已确认"只基于已加载应用"),无需分页/全量后端接口。
3. **榜单条数**:当前统一为 10 条(用户确认),后续是否需要在 UI 上可切换(O2)。
4. **贡献者字段格式**`contributor` 形如 `name<email>`,聚合与展示时需统一去除 `<...>` 取显示名;多个贡献者是否以 `;` 分隔需抽样确认。
5. **storeFilter 影响**:下载榜已按 Spark/APM 分榜;更新榜/荣耀榜按来源分布已实现。若 `storeFilter==='spark'` 仅显示 Spark 榜,需在主内容区做来源过滤。
---
## 7. 验收标准
- [ ] 侧边栏出现「排行榜」「荣耀榜」入口,点击正确切换页面
- [ ] 排行榜页:下载榜(Spark/APM)与更新榜(Spark/APM)各显示 Top 10,视觉与现有 AppCard 一致
- [ ] 荣耀榜页:贡献荣耀榜、更新荣耀榜各按来源 Top 10,且随 `apps` 加载实时更新;顶部致谢卡片正确显示
- [ ] 首页推荐:仅保留区域1(精选链接)+ 致谢卡片,区域2/3 已移除
- [ ] 全部新增/修改通过 `npm run lint``vue-tsc` 类型检查
- [ ] 打包(`dpkg-buildpackage`)成功,功能在 GUI 环境自测通过
---
*本文档基于 `ranking-preview.html` 设计原型与代码核查结论编写,作为后续开发与迭代的执行依据。*
@@ -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 升级通知"
```
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,150 @@
# App Detail Fixed Sidebar Scroll 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:** Keep the app detail modal's left action/meta column fixed on desktop while the right detail/review column scrolls independently.
**Architecture:** Reuse the existing `AppDetailModal.vue` popup and change only its internal layout classes/markers. The modal panel becomes a bounded, non-scrolling shell on desktop, while the right column becomes the desktop scroll container; mobile keeps the single-column scroll behavior.
**Tech Stack:** Vue 3 SFC, Tailwind CSS utilities, Vitest, Testing Library Vue.
---
## File Structure
- Modify: `src/components/AppDetailModal.vue` - adjust modal shell, left column, right scroll column, and scroll reset target.
- Modify: `src/__tests__/unit/AppDetailModal.test.ts` - assert modal shell and scroll column contract.
## Task 1: Add Layout Contract Test
**Files:**
- Modify: `src/__tests__/unit/AppDetailModal.test.ts`
- [ ] **Step 1: Add assertions to the popup modal test**
In `src/__tests__/unit/AppDetailModal.test.ts`, update `renders detail content inside a popup-style modal overlay` so the body after the `.modal-panel` assertion is:
```ts
const panel = overlay?.querySelector(".modal-panel");
expect(panel).toBeTruthy();
expect(panel?.className).toContain("overflow-hidden");
expect(panel?.className).toContain("lg:max-h-[85vh]");
expect(panel?.querySelector('[data-testid="detail-fixed-sidebar"]')).toBeTruthy();
expect(panel?.querySelector('[data-testid="detail-scroll-content"]')).toBeTruthy();
expect(
panel?.querySelector('[data-testid="detail-scroll-content"]')?.className,
).toContain("lg:overflow-y-auto");
```
- [ ] **Step 2: Run test to verify failure**
Run: `npm run test -- --run src/__tests__/unit/AppDetailModal.test.ts`
Expected: FAIL because `data-testid="detail-fixed-sidebar"`, `data-testid="detail-scroll-content"`, and the new modal classes are not present.
## Task 2: Implement Fixed Sidebar Layout
**Files:**
- Modify: `src/components/AppDetailModal.vue`
- Modify: `src/__tests__/unit/AppDetailModal.test.ts`
- [ ] **Step 1: Update modal shell classes**
In `src/components/AppDetailModal.vue`, change the `.modal-panel` class from:
```vue
class="modal-panel relative w-full max-w-5xl max-h-[85vh] overflow-y-auto overscroll-contain scrollbar-nowidth rounded-3xl border border-white/10 bg-white/95 px-6 pb-6 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
```
to:
```vue
class="modal-panel relative flex w-full max-w-5xl max-h-[85vh] overflow-y-auto overscroll-contain scrollbar-nowidth rounded-3xl border border-white/10 bg-white/95 px-6 pb-6 shadow-2xl dark:border-slate-800 dark:bg-slate-900 lg:max-h-[85vh] lg:overflow-hidden lg:pb-0"
```
- [ ] **Step 2: Move the return button into the fixed sidebar**
In `src/components/AppDetailModal.vue`, replace the top-level return button and main layout start with:
```vue
<!-- 主布局左侧信息 + 右侧内容 -->
<div class="flex w-full flex-col gap-6 lg:min-h-0 lg:flex-row">
<!-- 左侧图标版本来源按钮元信息 -->
<div
data-testid="detail-fixed-sidebar"
class="w-full flex-shrink-0 space-y-5 lg:w-72 lg:self-start lg:py-4"
>
<button
type="button"
class="inline-flex items-center gap-2 rounded-full border border-slate-200/70 bg-white/90 px-4 py-2 text-sm font-medium text-slate-600 shadow-lg backdrop-blur-sm transition hover:bg-slate-50 hover:text-slate-900 dark:border-slate-700 dark:bg-slate-800/90 dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-200"
@click="closeModal"
aria-label="返回"
>
<i class="fas fa-arrow-left"></i>
<span>返回</span>
</button>
```
Remove the old sticky top-level return button and the old left column opening:
```vue
<!-- 返回按钮 - sticky定位在模态框内部左上角滚动时始终可见 -->
<button ...>...</button>
<!-- 主布局左侧信息 + 右侧内容 -->
<div class="flex flex-col lg:flex-row gap-6">
<!-- 左侧图标版本来源按钮元信息 -->
<div class="w-full lg:w-72 flex-shrink-0 space-y-5">
```
- [ ] **Step 3: Make the right column the desktop scroll container**
In `src/components/AppDetailModal.vue`, change the right column opening from:
```vue
<div class="flex-1 min-w-0 space-y-5">
```
to:
```vue
<div
data-testid="detail-scroll-content"
class="min-w-0 flex-1 space-y-5 lg:max-h-[85vh] lg:overflow-y-auto lg:overscroll-contain lg:py-4 lg:pr-2"
>
```
- [ ] **Step 4: Update scroll reset target if needed**
In `src/App.vue`, keep the existing modal scroll reset selector if it still points at `.modal-panel`. If the right column needs reset instead, change the query to:
```ts
const modal = document.querySelector(
'[data-app-modal="detail"] [data-testid="detail-scroll-content"]',
);
```
Use `modal.scrollTop = 0` as it does today.
- [ ] **Step 5: Run focused test**
Run: `npm run test -- --run src/__tests__/unit/AppDetailModal.test.ts`
Expected: PASS.
- [ ] **Step 6: Run build verification**
Run: `npm run build:vite`
Expected: PASS.
- [ ] **Step 7: Commit implementation**
Run:
```bash
git add src/components/AppDetailModal.vue src/App.vue src/__tests__/unit/AppDetailModal.test.ts
git commit -m "fix(ui): pin detail modal sidebar"
```
Expected: commit succeeds.
@@ -0,0 +1,141 @@
# Review Avatar Display 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:** Show cached backend user avatar URLs beside comments in the app review list.
**Architecture:** Use the existing `AppReview.userAvatarUrl` field returned by the backend. `ReviewsPanel.vue` renders an avatar image when present and a stable fallback when missing or failed; tests cover the rendered avatar contract.
**Tech Stack:** Vue 3 SFC, Tailwind CSS utilities, Vitest, Testing Library Vue.
---
## File Structure
- Modify: `src/components/ReviewsPanel.vue` - render reviewer avatar/fallback in each review card and handle broken avatar images.
- Modify: `src/__tests__/unit/ReviewsPanel.test.ts` - add test coverage for avatar display.
## Task 1: Add Avatar Rendering Test
**Files:**
- Modify: `src/__tests__/unit/ReviewsPanel.test.ts`
- [ ] **Step 1: Add review with avatar test**
Append this test inside `describe("ReviewsPanel", () => { ... })` before the final closing brace:
```ts
it("shows reviewer avatars when available", async () => {
vi.mocked(fetchRatingSummary).mockResolvedValue({
averageRating: 5,
reviewCount: 1,
starCounts: { 5: 1 },
});
vi.mocked(fetchReviews).mockResolvedValue([
{
id: 3,
rating: 5,
content: "头像正常显示",
version: tags.version,
packageArch: tags.packageArch,
clientArch: tags.clientArch,
distro: tags.distro,
origin: tags.origin,
category: tags.category,
createdAt: "2026-05-19T00:00:00Z",
updatedAt: "2026-05-19T00:00:00Z",
userDisplayName: "Avatar User",
userAvatarUrl: "https://bbs.spark-app.store/avatar.png",
},
]);
render(ReviewsPanel, {
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: true },
});
const avatar = await screen.findByAltText("Avatar User 的头像");
expect(avatar).toHaveAttribute("src", "https://bbs.spark-app.store/avatar.png");
});
```
- [ ] **Step 2: Run test to verify failure**
Run: `npm run test -- --run src/__tests__/unit/ReviewsPanel.test.ts`
Expected: FAIL because no avatar image with alt text is rendered.
## Task 2: Render Review Avatars
**Files:**
- Modify: `src/components/ReviewsPanel.vue`
- Modify: `src/__tests__/unit/ReviewsPanel.test.ts`
- [ ] **Step 1: Update review article layout**
In `src/components/ReviewsPanel.vue`, replace the review `<article>` body with this structure:
```vue
<div class="flex gap-3">
<img
v-if="review.userAvatarUrl"
:src="review.userAvatarUrl"
:alt="`${review.userDisplayName || '星火用户'} 的头像`"
class="h-9 w-9 flex-shrink-0 rounded-full bg-slate-100 object-cover dark:bg-slate-800"
loading="lazy"
referrerpolicy="no-referrer"
@error="hideAvatar"
/>
<div
v-else
class="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full bg-slate-200 text-xs font-semibold text-slate-500 dark:bg-slate-800 dark:text-slate-300"
aria-hidden="true"
>
{{ review.userDisplayName?.slice(0, 1) || "星" }}
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center justify-between gap-3">
<strong class="truncate text-slate-700 dark:text-slate-200">
{{ review.userDisplayName || "星火用户" }}
</strong>
<span class="flex-shrink-0 text-xs text-slate-400">{{ review.rating }} </span>
</div>
<p class="mt-2 whitespace-pre-wrap text-slate-600 dark:text-slate-300">
{{ review.content || "暂无评论内容" }}
</p>
</div>
</div>
```
- [ ] **Step 2: Add avatar error handler**
In the `<script setup>` block of `src/components/ReviewsPanel.vue`, add:
```ts
const hideAvatar = (event: Event) => {
(event.target as HTMLElement).style.display = "none";
};
```
- [ ] **Step 3: Run focused test**
Run: `npm run test -- --run src/__tests__/unit/ReviewsPanel.test.ts`
Expected: PASS.
- [ ] **Step 4: Run build verification**
Run: `npm run build:vite`
Expected: PASS.
- [ ] **Step 5: Commit and push**
Run:
```bash
git add docs/superpowers/plans/2026-05-19-review-avatar-display.md src/components/ReviewsPanel.vue src/__tests__/unit/ReviewsPanel.test.ts
git commit -m "feat(reviews): show reviewer avatars"
git push origin Erotica
```
Expected: commit and push succeed.
@@ -0,0 +1,284 @@
# Client UI Polish 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:** Fix the client-side account, favorites, reviews, sync, restore, and shell UI issues reported in QA.
**Architecture:** Preserve existing Vue component boundaries. Add thin wrappers/helpers for modal account management, custom titlebar, review star controls, and sync restore candidate resolution. Backend-dependent review actions are client-side affordances only until backend endpoints exist.
**Tech Stack:** Vue 3 Composition API, TypeScript strict mode, Electron IPC, Tailwind CSS, Vitest, Testing Library Vue.
---
## File Structure
- Create `src/components/UserManagementModal.vue` to present `UserManagementView` as a global overlay.
- Create `src/components/WindowTitleBar.vue` for frameless titlebar UI and window controls.
- Modify `src/App.vue` to mount the new account modal, pass sync feedback to installed apps modal, pass favorite status to detail modal, and use restore candidate helper.
- Modify `src/components/AppSidebar.vue` and `src/components/AccountQuickMenu.vue` for overflow-safe labels and menu closing coverage.
- Modify `src/components/UserManagementView.vue` for cover-image rendering and modal-friendly layout.
- Modify `src/components/InstalledAppsModal.vue` to show sync feedback locally.
- Modify `src/components/FavoriteFolderSelector.vue` for normalized default-folder de-dupe and favorited-state text/actions.
- Modify `src/components/FavoriteFolderManager.vue` so the entire row content opens detail while checkbox stays isolated.
- Modify `src/components/AppDetailModal.vue` and `src/components/AppDetailPage.vue` to show `已收藏` state.
- Modify `src/components/ReviewsPanel.vue` for star rating, filters, user detail affordance, and disabled backend-dependent actions.
- Modify `src/modules/appListSync.ts` for `resolveCloudInstallCandidate()`.
- Modify `electron/main/index.ts`, `electron/preload/index.ts`, and `src/vite-env.d.ts` for frameless window controls.
- Add/update unit tests under `src/__tests__/unit/`.
---
### Task 1: Account Menu And User Management Modal
**Files:**
- Create: `src/components/UserManagementModal.vue`
- Modify: `src/App.vue`
- Modify: `src/components/AppSidebar.vue`
- Modify: `src/components/AccountQuickMenu.vue`
- Modify: `src/components/UserManagementView.vue`
- Modify: `src/global/typedefinition.ts`
- Modify: `src/modules/backendApi.ts`
- Modify: `src/global/authState.ts`
- Test: `src/__tests__/unit/AppSidebar.account.test.ts`
- Test: `src/__tests__/unit/UserManagementView.test.ts`
- Test: `src/__tests__/unit/App.account-placeholders.test.ts`
- Test: `src/__tests__/unit/accountTypes.test.ts`
- Test: `src/__tests__/unit/authState.test.ts`
- [ ] **Step 1: Write failing sidebar tests**
Add tests to `src/__tests__/unit/AppSidebar.account.test.ts` that verify each quick-menu action closes the menu, and that a user with empty `displayName` and long `username` still gets truncate/min-width classes.
- [ ] **Step 2: Run sidebar tests red**
Run: `npm run test -- --run src/__tests__/unit/AppSidebar.account.test.ts`
Expected: FAIL for missing fallback username coverage or quick-menu item truncation coverage.
- [ ] **Step 3: Implement sidebar overflow and close safety**
Update account label wrappers and quick-menu item labels with `min-w-0`, `truncate`, and explicit close-before-emit behavior already used by the parent.
- [ ] **Step 4: Write failing user management modal tests**
Update `src/__tests__/unit/App.account-placeholders.test.ts` so opening “用户管理” asserts a `role="dialog"` overlay exists and the main app grid/favorites frame is not replaced by `currentView === 'account'`.
- [ ] **Step 5: Write failing cover field tests**
Update `src/__tests__/unit/accountTypes.test.ts`, `authState.test.ts`, and `UserManagementView.test.ts` for optional `coverUrl` on `SparkUser` and visible cover rendering.
- [ ] **Step 6: Implement modal and cover rendering**
Add `UserManagementModal.vue`, add `showUserManagementModal` state in `App.vue`, keep `currentView` unchanged when opening user management, and pass through `close`, `open-forum`, `edit-profile`, `toggle-sync`, `sync-now`, and `refresh-downloads` events.
- [ ] **Step 7: Run account tests green**
Run: `npm run test -- --run src/__tests__/unit/AppSidebar.account.test.ts src/__tests__/unit/UserManagementView.test.ts src/__tests__/unit/App.account-placeholders.test.ts src/__tests__/unit/accountTypes.test.ts src/__tests__/unit/authState.test.ts`
Expected: PASS.
---
### Task 2: Favorites State And Interaction Polish
**Files:**
- Modify: `src/App.vue`
- Modify: `src/components/FavoriteFolderSelector.vue`
- Modify: `src/components/FavoriteFolderManager.vue`
- Modify: `src/components/AppDetailModal.vue`
- Modify: `src/components/AppDetailPage.vue`
- Modify: `src/modules/backendApi.ts`
- Test: `src/__tests__/unit/FavoriteFolderSelector.test.ts`
- Test: `src/__tests__/unit/FavoriteFolderManager.test.ts`
- Test: `src/__tests__/unit/AppDetailModal.test.ts`
- Test: `src/__tests__/unit/App.account-placeholders.test.ts`
- [ ] **Step 1: Write failing selector de-dupe tests**
Add a test where backend returns folder name ` 默认收藏夹 ` and assert only one default folder button appears.
- [ ] **Step 2: Run selector test red**
Run: `npm run test -- --run src/__tests__/unit/FavoriteFolderSelector.test.ts`
Expected: FAIL because current de-dupe compares exact name only.
- [ ] **Step 3: Normalize default folder names**
Trim folder names before default-folder comparison.
- [ ] **Step 4: Write failing detail favorite-state tests**
Add tests asserting a favorited app renders `已收藏` in `AppDetailModal` and emits the favorite action when clicked.
- [ ] **Step 5: Implement favorite state props**
Add `favorited`/`favoriteFolderName` props to detail components and compute current favorite metadata in `App.vue` from loaded folders/items.
- [ ] **Step 6: Write failing row click test**
Add a test that clicking the favorite row text/image area opens detail while clicking the checkbox only selects.
- [ ] **Step 7: Implement row-click behavior**
Make the favorite row content area larger/clickable and keep checkbox event isolated.
- [ ] **Step 8: Run favorites tests green**
Run: `npm run test -- --run src/__tests__/unit/FavoriteFolderSelector.test.ts src/__tests__/unit/FavoriteFolderManager.test.ts src/__tests__/unit/AppDetailModal.test.ts src/__tests__/unit/App.account-placeholders.test.ts`
Expected: PASS.
---
### Task 3: Review Panel Client UX
**Files:**
- Modify: `src/components/ReviewsPanel.vue`
- Modify: `src/global/typedefinition.ts`
- Modify: `src/modules/backendApi.ts`
- Test: `src/__tests__/unit/ReviewsPanel.test.ts`
- [ ] **Step 1: Write failing star rating test**
Add a test that clicks the “3 星” star button and verifies `submitReview` receives `rating: 3`.
- [ ] **Step 2: Write failing filter test**
Add reviews with different `packageArch` and `distro`; assert selecting an architecture or OS filter hides non-matching reviews.
- [ ] **Step 3: Write failing review action/user tests**
Add a test that reviewer avatar/name are buttons emitting/showing user detail affordance, and that like/reply/delete controls render with delete disabled unless local permission allows it.
- [ ] **Step 4: Run review tests red**
Run: `npm run test -- --run src/__tests__/unit/ReviewsPanel.test.ts`
Expected: FAIL because current UI uses select rating and lacks filters/actions.
- [ ] **Step 5: Implement star rating and filters**
Replace the native rating select with five star buttons and add local filter controls for package architecture and distro.
- [ ] **Step 6: Implement client-only review actions**
Render like/reply/delete buttons. Like/reply show a local “后端接口接入后可用” message. Delete is visible only for author/admin-compatible client data; otherwise omit or disable it.
- [ ] **Step 7: Run review tests green**
Run: `npm run test -- --run src/__tests__/unit/ReviewsPanel.test.ts`
Expected: PASS.
---
### Task 4: Sync Feedback And Cross-Origin Restore
**Files:**
- Modify: `src/App.vue`
- Modify: `src/components/InstalledAppsModal.vue`
- Modify: `src/modules/appListSync.ts`
- Test: `src/__tests__/unit/appListSync.test.ts`
- Test: `src/__tests__/unit/App.account-placeholders.test.ts`
- [ ] **Step 1: Write failing restore helper tests**
Add `resolveCloudInstallCandidate()` tests for exact origin/category match, same package fallback across origin, and no candidate.
- [ ] **Step 2: Run sync helper tests red**
Run: `npm run test -- --run src/__tests__/unit/appListSync.test.ts`
Expected: FAIL because helper is missing.
- [ ] **Step 3: Implement restore helper**
Export `resolveCloudInstallCandidate(item, apps)` from `appListSync.ts` and use it in `App.vue` `installCloudItems()`.
- [ ] **Step 4: Write failing sync feedback test**
Update `App.account-placeholders.test.ts` or `InstalledAppsModal.test.ts` to assert clicking sync in the installed-apps modal shows `同步完成` or an error message in that modal.
- [ ] **Step 5: Implement modal sync feedback prop**
Add `syncMessage` prop to `InstalledAppsModal.vue` and pass `syncStatusMessage` from `App.vue`.
- [ ] **Step 6: Run sync tests green**
Run: `npm run test -- --run src/__tests__/unit/appListSync.test.ts src/__tests__/unit/App.account-placeholders.test.ts src/__tests__/unit/InstalledAppsModal.test.ts`
Expected: PASS.
---
### Task 5: Frameless Window And Shell Polish
**Files:**
- Create: `src/components/WindowTitleBar.vue`
- Modify: `src/App.vue`
- Modify: `electron/main/index.ts`
- Modify: `electron/preload/index.ts`
- Modify: `src/vite-env.d.ts`
- Test: add or update appropriate unit tests for titlebar component and source config.
- [ ] **Step 1: Write failing titlebar component test**
Create a unit test that renders `WindowTitleBar.vue`, clicks minimize/maximize/close, and verifies IPC messages are sent.
- [ ] **Step 2: Write failing Electron config test or static check**
Add a lightweight test/static assertion that `BrowserWindow` is created with `frame: false`.
- [ ] **Step 3: Implement IPC handlers and titlebar**
Set `frame: false` in `BrowserWindow`; add IPC listeners for window minimize, toggle maximize, and close using existing close guard; add `WindowTitleBar.vue` with drag/no-drag regions.
- [ ] **Step 4: Mount titlebar in App.vue**
Place the titlebar at the top of the root layout and adjust sticky header offsets as needed.
- [ ] **Step 5: Verify category capsule color**
Run existing `CategoryBar.test.ts`; no code change needed if it already asserts `#2b7fff`.
Run: `npm run test -- --run src/__tests__/unit/CategoryBar.test.ts`
Expected: PASS.
---
### Task 6: Final Verification
**Files:**
- All files touched by previous tasks.
- [ ] **Step 1: Run targeted unit tests**
Run the union of tests touched by this plan:
```bash
npm run test -- --run src/__tests__/unit/AppSidebar.account.test.ts src/__tests__/unit/UserManagementView.test.ts src/__tests__/unit/App.account-placeholders.test.ts src/__tests__/unit/accountTypes.test.ts src/__tests__/unit/authState.test.ts src/__tests__/unit/FavoriteFolderSelector.test.ts src/__tests__/unit/FavoriteFolderManager.test.ts src/__tests__/unit/AppDetailModal.test.ts src/__tests__/unit/ReviewsPanel.test.ts src/__tests__/unit/appListSync.test.ts src/__tests__/unit/InstalledAppsModal.test.ts src/__tests__/unit/CategoryBar.test.ts
```
Expected: PASS.
- [ ] **Step 2: Run production build**
Run: `npm run build:vite`
Expected: PASS.
- [ ] **Step 3: Check diff whitespace**
Run: `git diff --check`
Expected: no output.
- [ ] **Step 4: Commit client changes**
Commit message: `fix(ui): polish account favorites reviews and shell`
- [ ] **Step 5: Push feature branch**
Push `fix/client-ui-account-favorites` for review or merge.
@@ -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.
@@ -0,0 +1,315 @@
# Spark Account Collections Client Design
## Goal
Extend the Spark Store account experience across the backend and Electron/Vue client so users can log in or register through the forum identity flow, see account management data, use comments and favorites when logged in, sync store-recognized installed apps, and batch install apps from cloud favorites without breaking anonymous browsing, installation, removal, or update features.
## Scope
### Included
1. Clone or prepare a clean working copy of `https://gitee.com/erotica-rbqs/spark-store` for client implementation.
2. Extend the existing FastAPI backend at `https://gitee.com/erotica-rbqs/spark-unionid-server` with account data APIs for favorite folders, favorite items, downloaded records, and richer user profile data.
3. Keep login identity based on the Flarum forum. The client posts credentials directly to Flarum, then sends only the Flarum token and user id to the backend.
4. The login modal provides a register action by opening the forum registration page in the system browser.
5. Replace the upper-left SparkStore title area with an account entry while preserving the logo. After login, replace the logo with the user's avatar and show the username.
6. Add a logged-in account quick menu with user management, favorites, forum home, edit forum profile, and logout actions.
7. Keep anonymous base usage intact: browsing, searching, detail viewing, software install, software uninstall, update center, and installed-app viewing must work without login.
8. Gate account-only features: comments, favorites, cloud favorite management, downloaded record history, and cloud sync require login and show a login/register prompt when used anonymously.
9. Convert app details from modal overlay to a main-content detail page that fills the current app-list area, with a back button returning to the previous list state.
10. Add favorite actions from the detail page. Users can select a favorite folder; if none exists, the backend creates a default folder.
11. Store favorites as application-level identities, not a fixed Spark/APM variant. Batch install selects the currently preferred available variant according to the active priority configuration.
12. Record logged-in user downloads to the cloud when the user clicks download. Downloads made while anonymous are not backfilled after login.
13. On each client start, refresh installed package lists in the background. For logged-in users, ask once before enabling automatic cloud sync; remember the choice.
14. Build the sync list only from store-recognized listed applications, including Spark and APM apps, excluding unknown packages and dependencies.
15. User management shows avatar, nickname, Flarum user level, forum home link, and forum profile edit link.
16. Users can manage multiple favorite folders with custom names, remove invalid/downlisted apps, select all apps in a folder, and send selected installable apps to the existing download/install queue.
17. Favorite folder entries that are not available on the current platform or architecture remain visible with status labels. Downlisted entries remain visible and can be batch removed.
### Excluded
1. Client-side Flarum account creation forms. Registration is a browser link to the forum registration page.
2. Forum profile editing inside the client. The client opens the forum profile page externally.
3. Offline-first conflict resolution for favorites. Backend state is authoritative; client caches may improve UX but do not merge conflicting edits.
4. Automatic install on startup. Users must explicitly send favorites or restore items to the download queue.
5. Admin moderation, report handling, or anti-spam systems.
## Existing Context
The current client is an Electron + Vue 3 + TypeScript application. The target repository is `https://gitee.com/erotica-rbqs/spark-store`.
Important existing integration points:
1. `src/App.vue` coordinates tabs, app loading, install queue integration, detail opening, installed-app modal, and update center.
2. `src/components/AppHeader.vue` owns the top search/settings/about area.
3. `src/components/AppSidebar.vue` owns the current upper-left logo/title area and category navigation.
4. `src/components/AppDetailModal.vue` currently renders app details as an overlay modal. It already handles Spark/APM merged apps and source switching.
5. `src/components/InstalledAppsModal.vue` renders installed apps and origin switching.
6. `src/modules/processInstall.ts` creates download queue items and sends `queue-install` IPC messages.
7. `src/global/storeConfig.ts` owns store URLs and hybrid priority rules through `getHybridDefaultOrigin`.
8. `electron/main/backend/install-manager.ts` exposes install, remove, `check-installed`, `list-installed`, and availability IPC handlers.
9. `list-installed` already supports optimized Spark checks with a `pkgnameList`, and full APM listing marks dependencies by desktop-entry availability. Sync filtering should reuse these facts rather than scanning arbitrary system packages.
The Spark developer manual identifies this repository as the current Electron GUI store (`apm-app-store` behavior), while `amber-pm` owns package-management semantics after commands leave the GUI.
## Backend Design
### User Profile Extension
The existing backend auth flow remains unchanged at the boundary: `POST /auth/flarum` receives `flarum_user_id` and `flarum_token`, validates the token owner, upserts the local user, and returns a Spark Store JWT.
The Flarum validation service should also extract user group data from the authenticated actor when available. The backend stores a compact `forum_level` string and optional `forum_groups` JSON/text summary. If Flarum does not expose groups, `forum_level` falls back to `论坛用户`.
`GET /me` should return existing profile fields plus the forum level fields needed by the client. Existing clients remain compatible because new fields are additive.
### Favorite Folders
Add backend tables:
1. `favorite_folders`: id, user_id, name, created_at, updated_at. Folder names are user-scoped and unique per user. A folder named `默认收藏夹` is created on first favorite action if the user has no folders.
2. `favorite_items`: id, folder_id, app_key, pkgname, name, category, icon_url, created_at. Items are unique per folder and `app_key`.
Favorites are stored as app-level identities. The canonical app key for favorites should be stable across Spark/APM variants when the same user-facing app exists in both sources:
```text
favorite_app_key = app:{category}:{pkgname}
```
The item keeps `pkgname`, display `name`, `category`, and optional `icon_url`. It does not permanently bind to Spark or APM. During install, the client resolves the current catalog entry and chooses Spark/APM according to current availability and priority rules.
Backend endpoints:
1. `GET /me/favorite-folders`: list folders with item counts.
2. `POST /me/favorite-folders`: create folder with custom name.
3. `PATCH /me/favorite-folders/{folder_id}`: rename folder.
4. `DELETE /me/favorite-folders/{folder_id}`: delete folder and its items.
5. `GET /me/favorite-folders/{folder_id}/items`: list items.
6. `POST /me/favorite-folders/{folder_id}/items`: add or idempotently keep an app favorite.
7. `DELETE /me/favorite-folders/{folder_id}/items/{item_id}`: remove one item.
8. `POST /me/favorite-folders/{folder_id}/items/bulk-delete`: remove selected items, including invalid/downlisted entries.
All endpoints require JWT.
### Downloaded Records
Add `downloaded_apps`: id, user_id, app_key, pkgname, name, category, selected_origin, version, package_arch, downloaded_at.
Client behavior:
1. If the user is logged in when clicking download, the client posts a downloaded record after queuing the install task.
2. If the user is anonymous, the download proceeds normally and no cloud record is written.
3. Logging in later does not backfill anonymous downloads.
Backend endpoints:
1. `GET /me/downloaded-apps`: list newest downloaded records with pagination.
2. `POST /me/downloaded-apps`: upsert or append a downloaded record. MVP can append history; duplicate suppression by `user_id`, `app_key`, and `selected_origin` is acceptable if tests define it.
### Installed Sync List
The existing `GET /me/app-list` and `PUT /me/app-list` endpoints remain the default cloud installed-app list.
Client sync payload contains only store-recognized listed apps:
1. App must exist in the current loaded Spark/APM catalog.
2. `category !== "unknown"`.
3. `isDependency !== true`.
4. App has usable `pkgname` and `origin`.
Unknown system packages, dependencies, and packages not in the store catalog are excluded.
## Client Design
### Account Entry And Login
Move the account entry into the upper-left logo/title area currently owned by the sidebar. The logo remains visible while anonymous. The title text becomes `登录 / 注册` with helper text `星火账号`.
After login:
1. The logo image is replaced by the user's avatar when available.
2. The main text is the user's display name or username.
3. Clicking the account entry opens a quick menu.
4. The quick menu includes: `用户管理`, `我的收藏`, `论坛首页`, `修改论坛资料`, and `退出登录`.
Login modal:
1. Contains forum account and password inputs.
2. Posts credentials directly to Flarum `/api/token`.
3. Sends the returned Flarum token and user id to the backend.
4. Provides `注册账号` button that opens the Flarum registration page externally.
5. Never logs or stores the forum password.
### Anonymous Behavior
Anonymous users can still:
1. Browse homepage and categories.
2. Search.
3. View app details.
4. Download/install apps.
5. Remove installed apps.
6. Use update center.
7. View installed apps.
When anonymous users use account-only actions, show a login/register prompt instead of blocking the whole page. Account-only actions are comments, submit review, favorite, cloud favorites, downloaded history, and cloud sync.
### Detail Page
Replace the overlay detail modal with a main-content detail page inside the same area currently used by `AppGrid` and `HomeView`.
State model:
1. `currentView` or equivalent distinguishes `list`, `home`, and `detail`.
2. Opening an app stores the previous list context and selected app.
3. Back returns to the prior list/search/category state.
4. Screen preview can remain an overlay because it is secondary media UI.
The detail page keeps existing detail capabilities:
1. Spark/APM merged app source switch.
2. Install/open/remove actions.
3. Metadata and screenshots.
4. Download count.
New detail capabilities:
1. Favorite button.
2. Favorite folder selector.
3. Comments/reviews panel with login prompt for anonymous users.
4. Download click writes a cloud downloaded record only when logged in.
### Favorites Management
The user management area includes favorite folder management.
Users can:
1. List favorite folders.
2. Create folders with custom names.
3. Rename folders.
4. Delete folders.
5. View folder items.
6. Remove selected items.
7. Batch remove invalid/downlisted items.
8. Select all available items and send them to the download queue.
Availability resolution is client-side because it depends on the current catalog, architecture, Spark/APM availability, store filter, and priority rules.
Favorite item states:
1. `installable`: found in catalog and current source/architecture can install a preferred variant.
2. `installed`: already installed locally.
3. `platform-unavailable`: item exists but neither Spark nor APM variant is usable under current store filter/capability.
4. `arch-unavailable`: item exists in catalog metadata but not for the current architecture.
5. `downlisted`: item no longer exists in the loaded catalog.
Batch install uses current preference:
1. If only one usable variant exists, use it.
2. If both Spark and APM variants exist, use `getHybridDefaultOrigin` and the current store filter/availability to choose.
3. If the preferred variant is unavailable, use the other usable variant.
4. If no usable variant exists, do not queue it and show the reason.
### Downloaded Records
When a logged-in user clicks download/install from detail, favorites, restore, or installed sync restore flows, the client records the selected app to the backend after the queue item is created.
Downloaded records are visible in user management. They are informational and do not alter the install queue automatically.
### Startup Installed Sync
On startup, the client refreshes installed packages in the background after the catalog is loaded enough to provide package lists.
Flow:
1. Load Spark/APM catalog as normal.
2. Call existing `list-installed` IPC for enabled origins. Use optimized package-name checks where possible for Spark and full APM listing where needed, following the current installed modal/update-center patterns.
3. Merge results with catalog metadata.
4. Filter to store-recognized non-dependency apps.
5. If logged in and the user has not made a cloud sync decision, ask once whether to enable automatic installed-list sync.
6. If enabled, upload the default app list via `PUT /me/app-list`.
7. If disabled or anonymous, keep the refreshed list local only.
The confirmation preference is stored locally. A user can later change it from user management.
## Data Flow
### Login
1. User opens login modal from the upper-left account entry.
2. Client posts credentials to Flarum `/api/token`.
3. Flarum returns token and user id.
4. Client posts token and user id to backend `/auth/flarum`.
5. Backend validates token owner, stores profile and forum level, and returns Spark JWT.
6. Client stores Spark JWT and profile in local auth state.
### Favorite Add
1. Logged-in user clicks favorite on detail page.
2. Client fetches or creates favorite folders if needed.
3. User selects a folder.
4. Client posts app-level identity to backend favorite item endpoint.
5. UI updates folder item count and favorite state.
### Batch Install From Favorites
1. User opens a favorite folder.
2. Client resolves each favorite item against current catalog and install facts.
3. UI shows installable, installed, unavailable, arch-unavailable, and downlisted states.
4. User selects installable items or clicks select all installable.
5. Client maps each item to the chosen Spark/APM `App` object based on current priority rules.
6. Client calls existing `handleInstall(app)` for each selected item.
7. If logged in, client records downloaded apps to backend after queueing.
## Error Handling
1. Login failure from Flarum or backend shows a local error in the login modal and clears partial auth state.
2. Expired backend JWT logs the user out or prompts re-login when an account endpoint returns `401`.
3. Favorites and downloaded-record failures do not block base install; show a non-fatal account sync error.
4. Startup sync failure does not block app startup; it shows a user-management warning and can be retried manually.
5. Batch install skips unavailable/downlisted items and reports counts per state.
6. Backend rejects extra fields and invalid lengths with `422`.
## Testing And Verification
Backend tests:
1. Favorite folder creation, default folder creation, rename, delete, and user isolation.
2. Favorite item add/remove/idempotency and folder scoping.
3. Downloaded record create/list and user isolation.
4. Forum level extraction fallback.
5. Existing auth/reviews/app-list tests remain passing.
6. Alembic migration upgrade succeeds.
Client tests:
1. Account entry anonymous/logged-in rendering and quick menu actions.
2. Login modal emits login and opens register URL.
3. Auth state persistence and backend token handling.
4. Detail page replaces the list area and back returns to list state.
5. Favorite folder selector login gating and folder selection.
6. Favorite availability resolver for installable, installed, platform-unavailable, arch-unavailable, and downlisted states.
7. Batch install selects Spark/APM variant according to current priority rules.
8. Startup sync filtering includes only store-listed non-dependency Spark/APM apps.
9. User management renders profile, forum level, forum links, favorite folders, downloaded records, and sync preference.
10. Existing install, update center, installed apps, search, and grid tests remain passing.
Final client verification:
1. `npm run test`
2. `npm run lint`
3. `npm run build:vite`
Final backend verification:
1. `.venv/bin/pytest -v`
2. `DATABASE_URL=<fresh test url> .venv/bin/alembic upgrade head`
## Implementation Notes
1. Use a clean client worktree or fresh clone from `https://gitee.com/erotica-rbqs/spark-store` before implementing. Do not mix previous untracked planning artifacts into code commits.
2. Keep backend and client commits separate.
3. Add `.superpowers/` to `.gitignore` so visual companion artifacts remain local.
4. Preserve existing IPC contracts for install and update flows.
5. Do not change `amber-pm` package-management behavior; only reuse GUI-side install queue paths.
6. Keep UI components focused: account entry/menu, login modal, detail page, favorite selector, user management, and favorite folder manager should be separate components rather than expanding `App.vue` further.
@@ -0,0 +1,374 @@
# Spark Account Reviews Sync Design
## Goal
Add a first-version account feature to Spark Store that uses the existing Flarum forum at `https://bbs.spark-app.store/` as the identity provider, while storing Spark Store-specific data in a new Python + MySQL backend.
The MVP covers:
1. Login with a Flarum account.
2. Show the logged-in user's avatar and display name in the client.
3. Show and submit app detail page comments with 1-5 star ratings.
4. Attach immutable automatic local tags to reviews and support review filtering by those tags.
5. Sync the user's local store-recognized app list so a new device can quickly reinstall old apps.
6. Create a new backend Git repository named `spark-store-backend`.
## Scope
### Included In MVP
1. Client-side Flarum token login.
2. Backend validation of Flarum tokens and backend JWT issuance.
3. User profile display in the Spark Store client.
4. Review list, rating summary, review creation, and editing the current user's own review.
5. Review filtering by automatic tags: app version, package architecture, client architecture, distro, origin, and category.
6. A default per-user cloud app list that is overwritten on each sync.
7. Restore UI that lets users choose cloud-list apps and add them to the existing install queue.
### Excluded From MVP
1. Admin moderation UI.
2. Review reports, bans, anti-spam scoring, or manual approval workflows.
3. Synchronizing reviews into Flarum discussions.
4. Multiple named app-list snapshots or historical list versions.
5. Syncing unknown system packages, dependencies, or every package from `dpkg`/APM.
6. Automatic unattended install on a new device.
## Existing Client Context
The current Spark Store client in this repository is an Electron + Vue 3 + TypeScript app.
Important existing integration points:
1. `src/components/AppDetailModal.vue` owns the app detail modal. It already renders app metadata, screenshots, install/open/remove actions, and download counts.
2. `src/components/InstalledAppsModal.vue` owns the installed-app list UI.
3. `src/App.vue` coordinates modal state, installed-app loading, detail opening, and existing install queue calls.
4. `electron/main/backend/install-manager.ts` already exposes `check-installed` and `list-installed` IPC handlers.
5. Existing install queue behavior should be reused for restore installs rather than creating a second install system.
## Architecture
Use the lightweight new-backend architecture confirmed during brainstorming.
Components:
1. Spark Store client: Vue/Electron UI, local app metadata, local package detection, and install queue integration.
2. Flarum forum: authoritative identity provider for forum username, display name, and avatar.
3. New Python backend: FastAPI service that verifies Flarum tokens, signs Spark Store JWTs, and owns reviews, ratings, and app-list sync data.
4. MySQL database: persistent storage for local user mappings, app keys, reviews, rating aggregates, and synced app-list items.
The new backend must not receive the user's forum password in the selected MVP flow.
## Authentication Flow
1. The client shows a login form for the Flarum username/email and password.
2. The client posts credentials directly to Flarum's token API.
3. Flarum returns an access token and user id.
4. The client sends the Flarum token and user id to the new backend `POST /auth/flarum`.
5. The backend calls Flarum API with that token to verify it and retrieve the user profile.
6. The backend upserts a local user row keyed by `flarum_user_id`.
7. The backend returns a Spark Store JWT plus public profile fields: display name, username, avatar URL, and Flarum user id.
8. The client stores the Spark Store JWT for backend calls and displays the avatar/name in the header or account area.
Logout clears local Spark Store auth state. MVP logout does not need to revoke the Flarum token remotely.
## Review And Rating Design
### App Identity
Reviews are keyed by a stable app key derived from store metadata:
```text
app_key = {origin}:{store_arch}:{category}:{pkgname}
```
Examples:
```text
spark:amd64-store:tools:spark-store
apm:amd64-apm:office:wps
```
This separates Spark and APM apps when they share a package name, while still allowing the UI to show each source independently in the existing hybrid detail modal.
### Automatic Review Tags
When a logged-in user writes a review, the client sends automatic tags derived from the currently viewed app and local system. The user can preview these tags but cannot edit them.
Required tags:
1. `origin`: `spark` or `apm`.
2. `category`: current store category.
3. `pkgname`: current package name.
4. `version`: current app/package version shown in the detail page.
5. `package_arch`: package architecture if known from installed-app data or store filename metadata.
6. `client_arch`: `window.apm_store.arch` such as `amd64`, `arm64`, or `loong64`.
7. `distro`: local Linux distribution id/version when available from the Electron main process.
If `package_arch` or `distro` cannot be detected, the client sends an empty string or `unknown`; the backend stores the value exactly as submitted after validation.
### Review UI
Add a `ReviewsPanel`-style component inside `AppDetailModal.vue`, below the existing app description and screenshot sections.
Behavior:
1. Anonymous users can read reviews and rating summary.
2. Anonymous users see a login prompt instead of the review composer.
3. Logged-in users can select a 1-5 rating and submit text content.
4. The composer displays the automatic tags as read-only pills.
5. The list supports filters for current version, current architecture, current distro, origin, category, and rating.
6. Users can switch filters to view comments under other versions or architectures.
7. The review list uses pagination or cursor-based loading to avoid loading all reviews at once.
### Review Rules
MVP review rules:
1. Rating must be an integer from 1 to 5.
2. Content must be non-empty after trimming and have a backend-enforced maximum length.
3. A user can have one active review per `app_key` plus exact automatic-tag tuple.
4. Submitting again for the same `app_key` and tag tuple updates the existing review.
5. Backend timestamps use UTC.
## App List Sync Design
### Upload Source
The client uses the existing installed-app flow as the source of local software state.
For MVP, upload only apps that satisfy all conditions:
1. App exists in the Spark/APM store catalog loaded by the client.
2. `category !== "unknown"`.
3. `isDependency !== true`.
4. The app has a usable `pkgname` and `origin`.
This intentionally excludes unknown system packages, dependencies, and packages that the store cannot reinstall safely.
### Sync UI
Extend `InstalledAppsModal.vue` with account-aware actions:
1. `同步到账号`: uploads the filtered installed app list as the user's default cloud list.
2. `从账号恢复`: fetches the default cloud list and opens a restore selection view.
The restore view shows each cloud item with one of these states:
1. Already installed locally.
2. Available to install on this client.
3. Not available for the current architecture/source.
The user explicitly selects items and starts restore. Restore uses the existing `handleInstall` and install queue path in the client.
### Cloud List Semantics
MVP maintains one default cloud app list per user.
Each successful sync replaces the previous default list. This avoids list merge conflicts in the first version.
## Backend Repository
Create a new sibling repository:
```text
/home/spark/Desktop/shenmo-spark-store/spark-store-backend
```
Initialize it as a new Git repository and set origin to:
```text
https://gitee.com/momen_official/spark-store-backend.git
```
The initial repository should include a `README.md`. Feature implementation can then add backend code, migrations, tests, and configuration templates.
## Backend Technology
Use:
1. FastAPI for HTTP APIs.
2. SQLAlchemy for ORM models.
3. Alembic for database migrations.
4. PyMySQL or mysqlclient for MySQL connectivity.
5. Pydantic settings for environment configuration.
6. Pytest for backend tests.
Configuration should come from environment variables, with `.env.example` committed and real `.env` ignored.
## Backend API
### Auth
`POST /auth/flarum`
Request:
```json
{
"flarum_user_id": "123",
"flarum_token": "..."
}
```
Response:
```json
{
"access_token": "spark-store-jwt",
"token_type": "bearer",
"user": {
"id": 1,
"flarum_user_id": "123",
"username": "shenmo",
"display_name": "shenmo",
"avatar_url": "https://..."
}
}
```
`GET /me`
Returns the current backend user profile from the Spark Store JWT.
### Reviews
`GET /apps/{app_key}/rating-summary`
Returns average rating, review count, and per-star counts.
`GET /apps/{app_key}/reviews`
Query parameters:
1. `version`
2. `package_arch`
3. `client_arch`
4. `distro`
5. `origin`
6. `category`
7. `rating`
8. `page` and `page_size`
`POST /apps/{app_key}/reviews`
Requires JWT. Creates or updates the current user's review for the same app and automatic-tag tuple.
Request:
```json
{
"rating": 5,
"content": "Works well on my machine.",
"tags": {
"origin": "apm",
"category": "office",
"pkgname": "wps",
"version": "1.0.0",
"package_arch": "amd64",
"client_arch": "amd64",
"distro": "deepin 25"
}
}
```
### App List Sync
`GET /me/app-list`
Requires JWT. Returns the current user's default cloud app list.
`PUT /me/app-list`
Requires JWT. Replaces the current user's default cloud app list.
Request:
```json
{
"client_arch": "amd64",
"distro": "deepin 25",
"items": [
{
"pkgname": "spark-store",
"origin": "spark",
"category": "tools",
"version": "5.1.1",
"package_arch": "amd64",
"app_name": "Spark Store",
"icon_url": "https://..."
}
]
}
```
`POST /me/app-list/install-plan`
Requires JWT. Accepts current client catalog/install facts and returns a normalized plan with installed, installable, and unavailable items. The client may also compute this locally, but the endpoint gives the backend a stable contract for future clients.
## Database Model
Tables:
1. `users`: local user id, Flarum user id, username, display name, avatar URL, timestamps.
2. `apps`: app key, pkgname, origin, store arch, category, latest seen version, timestamps.
3. `reviews`: app id, user id, rating, content, automatic tag columns, timestamps.
4. `user_app_lists`: user id, snapshot name, client arch, distro, timestamps.
5. `user_app_list_items`: list id, pkgname, origin, category, version, package arch, app name, icon URL, timestamps.
Indexes:
1. Unique `users.flarum_user_id`.
2. Unique `apps.app_key`.
3. Index `reviews.app_id` plus tag filter columns.
4. Unique review key for user, app, version, package arch, client arch, distro, origin, and category.
5. Unique default app list per user.
## Error Handling
Client behavior:
1. If Flarum login fails, show a login error without contacting the backend.
2. If backend token exchange fails, show a backend login error and clear partial auth state.
3. If review loading fails, keep the app detail page usable and show a retry affordance in the review panel.
4. If app-list sync fails, keep the local installed-app modal usable and show the failure message near the sync action.
5. If restore install queuing fails for one item, keep the remaining selected items visible and report which item failed.
Backend behavior:
1. Invalid Flarum token returns `401`.
2. Invalid JWT returns `401`.
3. Invalid app key, rating, or tag payload returns `422`.
4. Database errors return `500` with safe generic messages and structured server logs.
## Security Notes
1. The new backend never receives forum passwords in the selected MVP architecture.
2. Real secrets, JWT keys, database URLs, and Flarum tokens must not be committed.
3. The Electron client must avoid logging Flarum tokens and backend JWTs.
4. Backend JWT expiry should be finite; refresh can be handled by reauth in MVP.
5. CORS should be restricted to expected client origins in production.
## Testing And Verification
Client verification:
1. Unit tests for review tag construction.
2. Unit tests for installed-app sync filtering.
3. Component tests for review panel anonymous/logged-in states.
4. Component tests for sync and restore UI states.
5. Existing `npm run lint` and `npm run build:vite` after implementation.
Backend verification:
1. Unit/API tests for `/auth/flarum` with mocked Flarum responses.
2. API tests for review creation, update, listing, filtering, and rating summary.
3. API tests for app-list upload and retrieval.
4. Migration verification against MySQL or a compatible test database.
## Open Implementation Notes
1. Detecting `distro` should be done through Electron main process IPC, preferably by reading `/etc/os-release` and exposing a small safe object to the renderer.
2. Package architecture can come from installed-app data when available; otherwise parse from filename only if reliable, falling back to `unknown`.
3. The existing `AppDetailModal.vue` is already large, so review UI should be isolated into a new child component rather than expanding all logic inline.
4. Restore installation should reuse existing app lookup and `handleInstall` code to preserve Spark/APM origin behavior.
@@ -0,0 +1,43 @@
# App Detail Fixed Sidebar Scroll Design
## Goal
In the app detail popup, keep the left app action/meta area fixed on desktop while only the right content area scrolls. Preserve the existing modal/popup visual style and mobile behavior.
## Scope
This change only affects `src/components/AppDetailModal.vue` and its unit tests. It does not change app identity, review behavior, install behavior, or the surrounding `App.vue` modal flow.
## Desktop Layout
For `lg` and wider screens:
1. The modal overlay remains a centered popup with the existing dim background.
2. `.modal-panel` keeps the rounded card style but stops being the scroll container.
3. `.modal-panel` uses a bounded height and `overflow-hidden` so the popup itself stays fixed.
4. The modal body is split into two columns.
5. The left column contains the return button, app icon/name, source selector, install/open/remove/favorite buttons, and metadata. This column does not scroll with wheel movement over the right side.
6. The right column contains app details, screenshots, and reviews. This column is the independent vertical scroll container.
## Mobile Layout
Below `lg`, keep the existing stacked modal behavior. The modal remains scrollable as a single column so small screens can still access all controls and content.
## Scroll Behavior
Wheel scrolling over normal modal content should scroll the right content column on desktop. The overlay wheel guard remains in place so the background page does not scroll through the modal.
## Testing
Update `AppDetailModal.test.ts` to assert:
1. The popup still renders as a fixed modal overlay with `.modal-panel`.
2. `.modal-panel` uses `overflow-hidden` instead of being the primary vertical scroll container on desktop.
3. The left fixed column and right scroll column have stable test selectors.
## Acceptance Criteria
1. Desktop: left A column remains visually fixed while right B column scrolls.
2. Mobile: stacked layout remains usable.
3. Existing detail modal behavior remains intact.
4. Unit tests and Vite build pass.
@@ -0,0 +1,59 @@
# Client UI Polish Design
## Goal
Fix the client-side account, favorites, review, sync, and shell UI issues reported during QA without changing backend contracts in this pass.
## Scope
This pass is client-only. It can change Vue components, renderer state, Electron window chrome, tests, and docs. It must not invent successful backend behavior for review likes, replies, or deletes until backend endpoints exist.
## User-Visible Requirements
- Long account names must not overflow the sidebar account entry or account quick menu.
- Account quick-menu actions must close the menu immediately after selection.
- User management must open as a global modal/popup overlay instead of replacing only the right content frame.
- User management should render a profile-cover hero when a user has a background/cover URL, with a safe fallback when absent.
- Favorites should not show duplicate default-folder entries.
- The favorite selector must expose a visible new-folder action.
- Clicking a favorite item row should open app detail; the checkbox should remain only for bulk selection.
- The detail favorite button should show favorited state as `已收藏`, and opening it should allow switching folder or cancelling the favorite when client data can identify the existing item.
- Review rating input should use clickable/star UI instead of a native select.
- Reviews should expose architecture and OS/distro filters.
- Review cards should expose user detail entry points from avatar/name and show client-side action affordances for like/reply/delete. Delete must be visibly limited to the author/admin; persistence is deferred until backend APIs exist.
- The system title bar should be replaced by a frameless Electron window with an app-rendered title bar and window controls.
- Selected category capsule color must remain `#2B7FFF`.
- Manual sync must show visible feedback where the user clicked it.
- Restore-from-account should resolve cloud items by same origin first, then fall back to same package across Spark/APM when the exact origin is not loaded.
## Architecture
Keep existing component boundaries and make small additions:
- `App.vue` remains the state coordinator for account modals, favorites, sync, and restore.
- Add a small reusable `UserManagementModal.vue` wrapper around existing `UserManagementView.vue` instead of restructuring the view.
- Add a `WindowTitleBar.vue` component and Electron IPC handlers for minimize/maximize/close, preserving the existing close-to-tray guard.
- Keep review API calls in `backendApi.ts`; client-only review actions emit UI feedback until backend endpoints are added.
- Add pure helper functions for sync restore candidate resolution so cross-source matching can be unit-tested without mounting the full app.
## Data Flow
- Account menu emits actions to `App.vue`; `App.vue` toggles modal state and loads downloaded history before showing user management.
- Favorites are loaded from existing folder/item endpoints. `App.vue` computes current detail favorite metadata from loaded folders/items and passes status to detail components.
- Review filters are local to `ReviewsPanel.vue`; they filter loaded review records by package architecture and distro string.
- Sync feedback is stored in existing `syncStatusMessage` and passed to both `UserManagementView` and `InstalledAppsModal`.
- Restore resolution uses `resolveCloudInstallCandidate(item, apps)` with exact origin/category preference and package-name fallback.
## Deferred Backend Work
The following need backend API work later:
- Persistent review likes.
- Review replies and reply listing.
- Server-authorized review deletion.
- Fetching arbitrary forum user profile details and cover images. This client pass supports cover URLs when present in `SparkUser`; it does not scrape forum HTML.
## Testing
- Add/update unit tests for account modal behavior, quick-menu closing, user cover rendering, favorites selector normalization, star rating, review filters/actions, restore candidate resolution, and titlebar IPC/config.
- Run targeted Vitest files and `npm run build:vite` before completion.
+178
View File
@@ -0,0 +1,178 @@
# 测试记录文档(Testing Records
> 用途:每次**修改代码后打包测试**都要在此追加一条记录,避免再次出现「改了 URL/逻辑后更新功能异常」这类回归问题。
> 维护约定(用户 2026-08-13 明确要求):
> 1. **每次修改代码都必须打包测试**(开发/自测统一用 `./scripts/test-build.sh`,产物带 `-test` 标签)。
> 2. 打包前先在此文档新增一条记录:填写修改功能、建议测试方式、预期结果。
> 3. 打包完成(真机或构建通过后)**回填测试结果**(通过 / 失败 + 现象 + 修复动作)。
> 4. 涉及更新中心 / 下载 / 安装 / metalink / 网络 URL 等改动时,必须包含「更新中心实际勾选升级一个应用,观察日志是否推进到下载与安装」这一回归项。
---
## 记录格式(复制此模板填写)
```
### [版本号] 日期 — 一句话主题
- 修改功能:
- 涉及文件:
- 建议测试方式:
- 预期结果:
- 构建验证:vue-tsc / 打包(PASS / FAIL
- 测试结果:(待回填)
- 真机现象:
- 结论:通过 / 失败
- 失败修复:
```
---
## 历史记录
### [5.2.1.31-test] 2026-08-13 — 更新中心 hold 锁定标签 + 强制安装 + 修复安装失败误报"下载完成"
- 修改功能:
1. 更新中心识别 `apt-mark hold` 锁定的包:默认不可选中,显示「已锁定」标签;提供「强制安装」开关,开启后 spark 源 ssinstall 追加 `--allow-change-held-packages`
2. 修复安装失败误报:ssinstall 放弃安装时仍以退出码 0 结束,原逻辑误判成功导致 UI 显示「下载完成」;现检测 `放弃安装`/`dry-run测试仍然失败` 等标志判失败,并按任务来源显示「更新完成/更新失败」或「安装完成/安装失败」。
- 涉及文件:
- 后端:`update-center/types.ts``update-center/index.ts``update-center/service.ts``install-manager.ts`
- 前端:`typedefinition.ts``modules/updateCenter.ts``modules/processInstall.ts``components/update-center/UpdateCenterItem.vue``components/update-center/UpdateCenterList.vue``components/UpdateCenterModal.vue`
- 建议测试方式:
1. 更新中心打开,确认被 hold 的包(如 `code`)显示「已锁定」、复选框禁用、顶部提示条出现。
2. 勾选其它普通更新项 → 开始更新 → 观察日志推进到「正在获取 Metalink」→ 下载进度 → 安装完成(不是「下载完成」)。
3. 打开 `code` 的「强制安装」开关 → 复选框可用、标签变「将强制」→ 勾选并升级 → 观察是否正常升级(需本机 `code` 被 hold 才能验证)。
4. 回归:用「更新中心实际升级一个普通应用」验证 metalink/下载/安装全链路未被本次改动破坏(此前 28-test 因 metalinkUrl 域名误杀出过同类问题)。
- 预期结果:hold 项默认不可选;强制开关可单独升级 hold 包;安装失败时明确显示「安装失败」而非「下载完成」;普通更新链路正常。
- 构建验证:vue-tsc 通过(exit 0);`scripts/test-build.sh` 打包成功,产物 `spark-store_5.2.1.31-test_amd64.deb`
- 测试结果:真机验证发现"失效"现象(见下方 32-test 修复说明)。
- 真机现象:用户装 31-test 后从更新中心点更新,日志仅 `[14:28:23] 开始更新...` 一行后无进展。
- 结论:非代码 bug,是 31 新增的 hold 拦截**正确行为**——用户测试的包(code)被 `apt-mark hold` 锁定,31 默认拦截它;但用户未开启该行的「强制安装」开关就点开始更新,后端 `start()` 把它过滤掉后**静默 return**,前端无任何提示,造成"只有一行日志"的观感。
- 失败修复:32-test 修复后端 `start()` 静默拦截问题——对被 hold 且未强制的选中项,明确向前端发送 `install-complete` 失败通知(提示"已在更新中心默认跳过,请开启强制安装后重试"),不再静默卡住。
### [5.2.1.32-test] 2026-08-13 — 修复被 hold 包未强制时更新中心"静默卡开始更新"
- 修改功能:后端 `update-center/service.ts``start()` 不再对"被锁定(hold)且未开启强制安装"的选中项静默跳过。改为:先分离可启动项与被锁拦截项;对被锁拦截项向前端发送明确的 `install-complete` 失败通知(携带提示文案);仅对真正可启动的项调用 `addInstallTask`。前端 `processInstall.ts` 的 install-complete handler 会将失败解析为「更新失败:...」,用户能看到原因而非停在「开始更新...」。
- 涉及文件:`update-center/service.ts`(仅后端;前端复用已有的 install-complete 失败显示逻辑,无需改)。
- 建议测试方式:
1. 更新中心勾选一个**普通(未 hold)**的更新项 → 开始更新 → 日志应推进到「正在获取 Metalink」→ 下载进度 → 更新完成(确认 31 的 hold 改动未破坏普通更新链路)。
2. 勾选一个**被 hold** 的包(如 `code`)且**不开**其「强制安装」开关 → 开始更新 → 日志应显示「更新失败:...被系统锁定(hold)...请开启强制安装后重试」,而非只有「开始更新...」。
3. 开启该 hold 包的「强制安装」开关 → 勾选并升级 → 应正常进入下载/安装(验证强制路径)。
- 预期结果:普通更新正常;hold 未强制时给出明确失败提示而非静默卡住;hold 强制后可正常升级。
- 构建验证:vue-tsc 通过(exit 0);`scripts/test-build.sh` 打包成功,产物 `spark-store_5.2.1.32-test_amd64.deb`
- 测试结果:失败。
- 真机现象:用户装 32-test 后,对 `code` 开启「强制安装」开关再点「更新选中」,日志仍只有 `[14:59:28] 开始更新...` 一行后无进展;同时「更新选中」后该包从更新中心列表消失,未保留。
- 失败原因:
1. `service.ts``start()``taskIdByKey` 只存储了 `task.id`(数字),后续读取 `taskIdByKey.get(taskKey)?.forceHeld` 永远为 `undefined`,导致「强制安装」开关状态丢失;被 hold 包即使开启强制仍被当作未强制拦截。
2. held 阻断通知代码里 `webContents` 在通知循环之后才获取,导致通知实际未发出(但 32 中因原因 1 已把强制项也拦截,所以主要表现为卡住)。
3. `start()` 启动任务后主动从 `currentItems` 过滤掉已启动项,导致用户点击「更新选中」后包立刻从更新中心消失。
- 失败修复:见 33-test 记录。
### [5.2.1.33-test] 2026-08-13 — 修复强制安装开关失效 + 更新选中后列表消失 + 工具栏布局优化
- 修改功能:
1. 修复 `update-center/service.ts``taskIdByKey` 仅保存 `id` 导致 `forceHeld` 丢失的 bug:改为保存完整 `UpdateCenterStartTask` 对象,确保「强制安装」开关状态能正确传递到 `addInstallTask`
2. 提前获取 `webContents` 到 held 阻断通知之前,确保被 hold 且未强制的项能向前端发送明确的 `install-complete` 失败通知。
3. 移除 `start()` 中启动后从更新中心 `items` 删除已选包的逻辑,保持列表不变,等待用户刷新或任务完成后再消失。
4. 优化 `UpdateCenterToolbar.vue`:将「全选」复选框和已选计数移到「更新选中」按钮同一行,缩短选择-执行操作路径。
- 涉及文件:
- 后端:`electron/main/backend/update-center/service.ts`
- 前端:`src/components/update-center/UpdateCenterToolbar.vue`
- 建议测试方式:
1. 更新中心勾选普通未 hold 包 → 开始更新 → 日志推进到「正在获取 Metalink」→ 下载/安装完成(非「下载完成」、非卡死)。
2. 被 hold 包(如 `code`)不开「强制安装」→ 开始更新 → 明确提示「更新失败:...被系统锁定(hold)...请开启强制安装后重试」,不只有「开始更新...」。
3. 被 hold 包开启「强制安装」→ 勾选并升级 → 应正常进入下载/安装流程(ssinstall 命令带 `--allow-change-held-packages`)。
4. 点击「更新选中」后,已选包仍保留在更新中心列表中,不会立即消失。
5. 观察更新中心工具栏:「全选」复选框、已选计数、「更新选中」按钮在同一行右侧,操作便捷。
- 预期结果:强制安装真正生效;hold 未强制有明确失败提示;普通更新链路正常;更新选中后列表保留;工具栏操作更紧凑。
- 构建验证:vue-tsc 通过(exit 0);`scripts/test-build.sh` 打包成功,产物 `spark-store_5.2.1.33-test_amd64.deb`
- 测试结果:通过(用户确认优化与更新处理正确)。
- 真机现象:用户装 33-test 后确认强制安装开关生效、hold 未强制有明确失败提示、普通更新链路正常。
- 结论:通过
- 遗留反馈:选中执行更新后,包进入下载列表却仍显示在更新中心(33-test 误删了"从列表移除已启动项"逻辑);且全选按钮在无可选项时仍可点、无提示。两处已在 34-test 修复。
### [5.2.1.34-test] 2026-08-13 — 恢复更新后从列表移除 + 无可选项时全选禁用提示
- 修改功能:
1. 恢复 33-test 误删的逻辑:更新中心「更新选中」启动任务后,已启动项从更新中心 `items` 移除(被 hold 未强制拦截的项不在此列,仍保留供用户开强制重试)。更新中心只展示待更新项,避免与下载队列重复显示。
2. `modules/updateCenter.ts` 新增 `selectableCount` computed(可选项数 = 未被忽略且非"held 未强制"的项数)。
3. `UpdateCenterToolbar.vue`:当 `selectableCount === 0`(全是 hold 未强制、全是忽略项、或完全没有可更新软件)时,全选复选框 `:disabled` 且整体置灰,右侧文案改为「无可更新项」并带提示 title。
- 涉及文件:
- 后端:`electron/main/backend/update-center/service.ts`
- 前端:`src/modules/updateCenter.ts``src/components/UpdateCenterModal.vue``src/components/update-center/UpdateCenterToolbar.vue`
- 建议测试方式:
1. 勾选若干普通更新项 → 更新选中 → 这些包应从更新中心列表消失,仅出现在下载队列(之前 33-test 会残留,现恢复)。
2. 当列表里所有项都是 hold 未强制 / 已忽略,或列表为空时:全选复选框应禁用置灰、文案显示「无可更新项」,点击无反应;「更新选中」按钮也因无选中而禁用。
- 预期结果:更新选中后不在更新中心残留;无可选项时全选禁用并提示。
- 构建验证:vue-tsc 通过(exit 0);`scripts/test-build.sh` 打包成功,产物 `spark-store_5.2.1.34-test_amd64.deb`
- 测试结果:待真机回填(本机无 GUI)。34-test 用户已确认更新后从列表移除、全选禁用提示均生效;但反馈开启强制后 code 仍更新失败(见 35-test 根因)。
- 真机现象:
- 结论:通过 / 失败
- 失败修复:
### [5.2.1.35-test] 2026-08-13 — 修复强制安装在 ssinstall 本地文件模式误加 apt 标志
- 修改功能:`install-manager.ts` 中 spark 源的 ssinstall 命令构建逻辑。此前对**本地 .deb 文件模式**(`metalinkUrl && filename`)也追加了 `--allow-change-held-packages`,但该选项不是 ssinstall 支持的参数(ssinstall 用法 `ssinstall [选项] <deb路径>`,选项仅自身专用),会被其透传给内部 `dirname`/`basename` 导致参数解析失败、包名丢失,进而 `E: 无法定位软件包` / `Package manager quit with exit code` 失败。
- 本地 .deb 文件模式:改走 `dpkg` 直接安装,dpkg 安装本地文件**不受 apt `hold` 限制**hold 仅拦截 `apt install <包名>` 从仓库升级),故**不再追加**该标志。
- 仓库模式(`ssinstall pkgname`):从 apt 仓库拉取,hold 会拦截,保留 `forceHeld` 时追加 `--allow-change-held-packages`
- 涉及文件:`electron/main/backend/install-manager.ts`(仅后端)。
- 建议测试方式:开启 `code`(被 hold)的「强制安装」开关 → 勾选升级 → 日志应正常进入 ssinstall 本地 .deb 安装,不再出现 `dirname/basename 未识别的选项``无法定位软件包`,最终「更新完成」。
- 预期结果:强制安装本地 .deb 不再因误加 apt 标志而失败;普通(未 hold)与仓库模式行为不变。
- 构建验证:vue-tsc 通过(exit 0);`scripts/test-build.sh` 打包成功,产物 `spark-store_5.2.1.35-test_amd64.deb`
- 测试结果:失败(用户 15:55 日志)。
- 真机现象:开 code 强制 → 更新 → `E: 在更改保留软件包的同时使用了 -y 选项,但没有搭配 --allow-change-held-packages.``dry-run测试仍然失败,放弃安装` → 更新失败。
- 根因:实测证实 ssinstall 本地 .deb 模式在 `dpkg -i` 失败后兜底 `aptss install <deb> -yfq`aptss/apt 对 held 包用 `-y` 强制要求 `--allow-change-held-packages`;而 ssinstall 不识别该参数(透传给 dirname/basename 崩溃),故 35-test 去掉标志后仍被 apt 拒绝。35-test 方案(依赖 dpkg 不拦 hold)不成立。
- 失败修复:见 36-test(改用 unhold/hold 包裹策略)。
### [5.2.1.36-test] 2026-08-13 — 强制安装改用 apt-mark unhold/hold 包裹(绕开 ssinstall 不支持该标志)
- 修改功能:`install-manager.ts` 的强制安装(forceHeld)策略从"传 `--allow-change-held-packages` 给 ssinstall"改为"安装前 `pkexec apt-mark unhold`、安装后(finally`pkexec apt-mark hold` 恢复锁定"。
- 新增模块函数 `runAptMark(action, pkgname)``checkSuperUserCommand()` 取 pkexec,直接用 `pkexec apt-mark <hold|unhold> pkgname`**不经过 shell-caller.sh**,因其白名单仅放行 apm/aptss/ssinstall,否则报"拒绝执行")。
- `runInstallPhase``try` 开头若 `task.forceHeld && task.origin==='spark'``runAptMark("unhold")``finally` 中无论成败都 `runAptMark("hold")` 恢复原锁定状态。
- 移除 spark 源 ssinstall 命令中任何 `--allow-change-held-packages`ssinstall 不识别)。
- 涉及文件:`electron/main/backend/install-manager.ts`(仅后端;forceHeld 字段链路 service.ts→QueueInstallPayload→task 已就绪)。
- 建议测试方式:开启 `code`(被 hold)的「强制安装」开关 → 勾选升级 → 日志应出现「正在解除系统锁定(hold)...」→ ssinstall 本地安装成功 → 最终「更新完成」;另查 `apt-mark showhold` 确认安装后 code 仍被锁定(状态恢复)。
- 预期结果:强制安装 held 包成功;安装后 hold 状态自动恢复,不破坏用户原本的锁定。
- 构建验证:vue-tsc 通过(exit 0);`scripts/test-build.sh` 打包成功,产物 `spark-store_5.2.1.36-test_amd64.deb`
- 测试结果:未真机验证即发现更优方案(见 37-test)。36-test 的 `runAptMark``pkexec apt-mark` 会弹额外权限框,且 apt-mark 不在 policykit 免密 exec.path 内可能直接失败。
- 失败修复:见 37-test(合并进商店已有免密 pkexec)。
### [5.2.1.37-test] 2026-08-13 — 强制安装合并进免密 pkexec(不重复请求权限)
- 修改功能:用户指出"商店已有提权配置,unhold/hold 不必再请求权限"。调查确认 policykit 规则(`extras/store.spark-app.spark-store.policy``pkg/.../ssinstall.policy`)仅对 `exec.path=/opt/spark-store/extras/shell-caller.sh``/usr/local/bin/ssinstall``allow_any=yes`(免密)。`pkexec apt-mark` 不在任何 policy 的 exec.path → 弹额外密码框且可能失败。
- `extras/shell-caller.sh` 新增 `force-ssinstall` 分支:在一次已提权的 pkexec 会话内执行 `apt-mark unhold <pkg>``ssinstall <deb> [额外参数] --native``apt-mark hold <pkg>`;unhold 失败仅警告不阻断,hold 无论成败都恢复;参数用双引号防注入、空参校验。
- `install-manager.ts``forceHeld && origin==='spark' && 本地 .deb` 时,构造命令走 `shell-caller force-ssinstall <pkg> <deb> --delete-after-install --no-create-desktop-entry`(即复用 ssinstall 那条免密 pkexec,仅一次权限请求);移除 36-test 的 `runAptMark` helper 及 runInstallPhase 的 try/finally 单独 pkexec apt-mark 调用。
- 涉及文件:`extras/shell-caller.sh``electron/main/backend/install-manager.ts`
- 建议测试方式:开启 `code`(被 hold)「强制安装」→ 勾选升级 → 应**仅弹一次**权限框(与平时 ssinstall 一致)→ 日志 ssinstall 本地安装成功 → 更新完成;装后 `apt-mark showhold` 确认 code 仍锁定(已自动恢复)。
- 预期结果:强制安装成功且只请求一次权限;hold 状态自动恢复。
- 构建验证:vue-tsc 通过(exit 0);`bash -n shell-caller.sh` 语法 OK`scripts/test-build.sh` 打包成功,产物 `spark-store_5.2.1.37-test_amd64.deb`
- 测试结果:待真机回填(本机无 GUI)。
- 真机现象:
- 结论:通过 / 失败
- 失败修复:
### [5.2.1.29-test / 5.2.1.30-test] 2026-08-13 — 修复更新中心卡"开始更新..." + metalinkUrl 域名误杀清理
- 修改功能:
1. 29-test 修复 metalinkUrl 校验写死 `erotica.spark-app.store` 误杀真实 CDN 域 `d.spark-app.store`:放宽为 `*.spark-app.store` 全子域 + https + path.posix.normalize 折叠 `./` + 拒绝 `..` 越界。
2. 30-test 清理调试期噪声([debug] 日志、FILENAME_PATTERN 拆分、stream error 监听、processInstall 自监听),保留真正修复。
- 涉及文件:`install-manager.ts``processInstall.ts`
- 建议测试方式:更新中心勾选并升级 `trae-cn` 等普通应用,观察日志推进到「正在获取 Metalink 文件」→ 下载进度。
- 预期结果:更新中心更新功能恢复正常,不再卡「开始更新...」或「下载失败: Metalink URL 不合法」。
- 构建验证:vue-tsc 通过;打包成功(29/30-test)。
- 测试结果:用户确认 29-test 修复有效。
- 真机现象:29-test 更新中心升级 trae-cn 正常推进到下载阶段。
- 结论:通过
### [5.2.1.26-test ~ 5.2.1.28-test] 2026-08-13 — 更新中心"更新"卡死的多次排查
- 修改功能:
1. 26-test:抽离 `addInstallTask(payload, sender)``service.ts``start()` 直接调用(修复 `webContents.send("queue-install")` 主进程自环 bug,任务从未入队);并加 FILENAME_PATTERN、各 return 分支补 install-complete 通知、metalink 流错误监听、[debug] 日志(部分属调试噪声,后续 30-test 回退)。
2. 28-test:暴露真正根因 metalinkUrl 域名校验误杀 `d.spark-app.store`(见 29-test 修复)。
- 涉及文件:`install-manager.ts``update-center/service.ts`
- 建议测试方式:更新中心实际升级应用 + 普通安装对照。
- 预期结果:更新中心任务能真正进入下载队列并推进。
- 构建验证:vue-tsc 通过;打包成功。
- 测试结果:
- 28-test 日志首次暴露 metalinkUrl 域名误杀(见上方 29-test 修复)。
- 结论:根因定位完成,29-test 修复通过
---
## 回归测试 checklist(每次打包必做核心项)
- [ ] `vue-tsc --noEmit` 通过,`scripts/test-build.sh` 打包成功
- [ ] **更新中心**:勾选并升级一个普通应用,日志推进 开始更新 → 正在获取 Metalink → 下载进度 → 安装完成(确认不是「下载完成」且非「开始更新...」卡死)
- [ ] **普通安装**:应用详情页安装一个应用,确认链路正常
- [ ] **忽略功能**:更新中心忽略/取消忽略一项,确认沉底与不可选
- [ ] **hold/强制**(若改动更新中心):被 hold 包默认不可选、强制开关可单独升级
- [ ] 安装失败时 UI 明确显示「安装失败」而非「下载完成」
+1
View File
@@ -26,6 +26,7 @@ linux:
Categories: "System;"
mimeTypes:
- "x-scheme-handler/spk"
- "x-scheme-handler/apt"
target:
- "AppImage"
- "deb"
File diff suppressed because it is too large Load Diff
+18 -25
View File
@@ -2,12 +2,13 @@
* /
* install-manager.ts update-center 使
*/
import { spawn, ChildProcess } from "node:child_process";
import { spawn } from "node:child_process";
import { createWriteStream } from "node:fs";
import * as fs from "node:fs";
import * as path from "node:path";
import axios from "axios";
import pino from "pino";
import { findExecutable, SUPER_USER_COMMAND_CANDIDATES } from "./superuser";
const logger = pino({ name: "shared-installer" });
@@ -34,7 +35,6 @@ export interface DownloadResult {
* install-manager.ts
*/
export const downloadPackage = async ({
pkgname,
metalinkUrl,
filename,
downloadDir,
@@ -90,6 +90,7 @@ export const downloadPackage = async ({
const aria2Args = [
`--dir=${downloadDir}`,
"--allow-overwrite=true",
"--async-dns=false",
"--summary-interval=1",
"--connect-timeout=10",
"--timeout=15",
@@ -105,8 +106,10 @@ export const downloadPackage = async ({
onStatus?.("downloading");
// 下载重试逻辑:每次超时时间递增,最多3次
const timeoutList = [3000, 5000, 15000];
// 下载重试逻辑:共10次,指数退避,首次3秒,末次1分钟
const timeoutList = [
3000, 4500, 6500, 9000, 13000, 18000, 26000, 36000, 50000, 60000,
];
let retryCount = 0;
let downloadSuccess = false;
@@ -225,7 +228,6 @@ export interface InstallOptions {
* install-manager.ts
*/
export const installPackage = async ({
pkgname,
filePath,
origin,
superUserCmd,
@@ -265,8 +267,6 @@ export const installPackage = async ({
env: process.env,
});
let stdout = "";
let stderr = "";
let logBuffer = "";
let logBufferTimer: NodeJS.Timeout | null = null;
const LOG_FLUSH_MS = 100;
@@ -295,12 +295,10 @@ export const installPackage = async ({
signal?.addEventListener("abort", abortHandler, { once: true });
child.stdout?.on("data", (data) => {
stdout += data.toString();
bufferedSendLog(data.toString());
});
child.stderr?.on("data", (data) => {
stderr += data.toString();
bufferedSendLog(data.toString());
});
@@ -348,21 +346,16 @@ export const checkApmAvailable = async (): Promise<boolean> => {
*
*/
export const checkSuperUserCommand = async (): Promise<string> => {
return new Promise((resolve) => {
const child = spawn("which", ["/usr/bin/pkexec"]);
let stdout = "";
child.stdout?.on("data", (data) => {
stdout += data.toString();
});
child.on("close", (code) => {
if (code === 0) {
resolve(stdout.trim());
} else {
resolve("");
if (process.getuid?.() === 0) return "";
for (const command of SUPER_USER_COMMAND_CANDIDATES) {
const superUserCmd = await findExecutable(command);
if (superUserCmd.length > 0) {
logger.info(`找到提升权限命令: ${superUserCmd}`);
return superUserCmd;
}
});
child.on("error", () => {
resolve("");
});
});
}
logger.error("没有找到提升权限的命令 pkexec!");
return "";
};
File diff suppressed because it is too large Load Diff
+49
View File
@@ -0,0 +1,49 @@
import { spawn } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
export const SUPER_USER_COMMAND_CANDIDATES = [
"/usr/bin/pkexec",
"/run/wrappers/bin/pkexec",
];
const WHICH_TIMEOUT_MS = 5000;
export const findExecutable = async (command: string): Promise<string> => {
if (path.isAbsolute(command)) {
try {
await fs.promises.access(command, fs.constants.X_OK);
return command;
} catch {
return "";
}
}
return await new Promise<string>((resolve) => {
const child = spawn("which", [command]);
let stdout = "";
let settled = false;
const timer = setTimeout(() => {
child.kill();
finish("");
}, WHICH_TIMEOUT_MS);
function finish(result: string) {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(result);
}
child.stdout?.on("data", (data) => {
stdout += data.toString();
});
child.on("close", (code) => {
finish(code === 0 ? stdout.trim() : "");
});
child.on("error", () => {
finish("");
});
});
};
@@ -1,5 +1,4 @@
import { join } from "node:path";
import { downloadPackage, type DownloadResult } from "../shared-installer";
import { downloadPackage } from "../shared-installer";
import type { UpdateCenterItem } from "./types";
export interface Aria2DownloadResult {
@@ -1,9 +1,16 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { dirname } from "node:path";
import { join } from "node:path";
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 = "|";
@@ -77,3 +84,15 @@ export const applyIgnoredEntries = (
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;
});
};
+316 -36
View File
@@ -1,7 +1,12 @@
import { spawn } from "node:child_process";
import pino from "pino";
import { BrowserWindow, ipcMain } from "electron";
const logger = pino({ name: "updateCenter" });
import { SHELL_CALLER_PATH } from "../shared-installer";
import { findExecutable, SUPER_USER_COMMAND_CANDIDATES } from "../superuser";
import {
buildInstalledSourceMap,
mergeUpdateSources,
@@ -12,6 +17,7 @@ import {
import { resolveUpdateItemIcons } from "./icons";
import {
createUpdateCenterService,
type StoreFilter,
type UpdateCenterIgnorePayload,
type UpdateCenterService,
type UpdateCenterStartTask,
@@ -53,7 +59,7 @@ const APTSS_LIST_UPGRADABLE_COMMAND = {
command: "bash",
args: [
"-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'",
],
};
@@ -162,9 +168,9 @@ const loadAptssItemMetadata = async (
| { item: UpdateCenterItem; warning?: undefined }
| { item: null; warning: string }
> => {
console.log(`[DEBUG] Loading APTSS metadata for ${item.pkgname}`);
logger.debug(`[DEBUG] Loading APTSS metadata for ${item.pkgname}`);
const printUrisCommand = getAptssPrintUrisCommand(item.pkgname);
console.log(
logger.debug(
`[DEBUG] APTSS command: ${printUrisCommand.command} ${printUrisCommand.args.join(" ")}`,
);
@@ -172,11 +178,11 @@ const loadAptssItemMetadata = async (
printUrisCommand.command,
printUrisCommand.args,
);
console.log(`[DEBUG] APTSS metadata result code: ${metadataResult.code}`);
console.log(
logger.debug(`[DEBUG] APTSS metadata result code: ${metadataResult.code}`);
logger.debug(
`[DEBUG] APTSS metadata stdout: ${metadataResult.stdout.substring(0, 500)}`,
);
console.log(
logger.debug(
`[DEBUG] APTSS metadata stderr: ${metadataResult.stderr.substring(0, 500)}`,
);
@@ -185,12 +191,19 @@ const loadAptssItemMetadata = async (
metadataResult,
);
if (commandError) {
console.log(`[DEBUG] APTSS metadata error: ${commandError}`);
logger.debug(`[DEBUG] APTSS metadata error: ${commandError}`);
return { item: null, warning: commandError };
}
const metadata = parsePrintUrisOutput(metadataResult.stdout);
console.log(`[DEBUG] APTSS parsed metadata:`, metadata);
if (metadata) {
logger.debug(`[DEBUG] APTSS parsed metadata:`, {
...metadata,
downloadUrl: `${metadata.downloadUrl}.metalink`,
});
} else {
logger.debug(`[DEBUG] APTSS parsed metadata:`, metadata);
}
if (!metadata) {
return {
@@ -349,61 +362,155 @@ 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 (
storeFilter: StoreFilter = "both",
runCommand: UpdateCenterCommandRunner = runCommandCapture,
): Promise<UpdateCenterLoadItemsResult> => {
logger.debug(
`[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),
]);
logger.debug(
`[UpdateCenter] sparkEnabled=${sparkEnabled}, apmEnabled=${apmEnabled}`,
);
const [aptssResult, apmResult, aptssInstalledResult, apmInstalledResult] =
await Promise.all([
runCommand(
sparkEnabled
? runCommand(
APTSS_LIST_UPGRADABLE_COMMAND.command,
APTSS_LIST_UPGRADABLE_COMMAND.args,
),
runCommand("apm", ["list", "--upgradable"]),
runCommand(
)
: Promise.resolve({ code: 0, stdout: "", stderr: "" }),
apmEnabled
? runCommand("apm", ["list", "--upgradable"])
: Promise.resolve({ code: 0, stdout: "", stderr: "" }),
sparkEnabled
? runCommand(
DPKG_QUERY_INSTALLED_COMMAND.command,
DPKG_QUERY_INSTALLED_COMMAND.args,
),
runCommand("apm", ["list", "--installed"]),
)
: Promise.resolve({ code: 0, stdout: "", stderr: "" }),
apmEnabled
? runCommand("apm", ["list", "--installed"])
: Promise.resolve({ code: 0, stdout: "", stderr: "" }),
]);
logger.debug(
`[UpdateCenter] aptssResult: code=${aptssResult.code}, stdout=${aptssResult.stdout.substring(0, 500)}, stderr=${aptssResult.stderr.substring(0, 500)}`,
);
logger.debug(
`[UpdateCenter] apmResult: code=${apmResult.code}, stdout=${apmResult.stdout.substring(0, 500)}, stderr=${apmResult.stderr.substring(0, 500)}`,
);
logger.debug(
`[UpdateCenter] aptssInstalledResult: code=${aptssInstalledResult.code}, stdout=${aptssInstalledResult.stdout.substring(0, 500)}`,
);
logger.debug(
`[UpdateCenter] apmInstalledResult: code=${apmInstalledResult.code}, stdout=${apmInstalledResult.stdout.substring(0, 500)}`,
);
const aptssAvailable =
sparkEnabled && (aptssResult.code === 0 || aptssInstalledResult.code === 0);
const warnings = [
getCommandError("aptss upgradable query", aptssResult),
getCommandError("apm upgradable query", apmResult),
getCommandError("dpkg installed query", aptssInstalledResult),
getCommandError("apm installed query", apmInstalledResult),
aptssAvailable
? getCommandError("aptss upgradable query", aptssResult)
: null,
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);
const aptssItems =
aptssResult.code === 0
aptssAvailable && aptssResult.code === 0
? parseAptssUpgradableOutput(aptssResult.stdout)
: [];
const apmItems =
apmResult.code === 0 ? parseApmUpgradableOutput(apmResult.stdout) : [];
if (aptssResult.code !== 0 && apmResult.code !== 0) {
throw new Error(warnings.join("; "));
}
const installedSources = buildInstalledSourceMap(
aptssInstalledResult.code === 0 ? aptssInstalledResult.stdout : "",
apmInstalledResult.code === 0 ? apmInstalledResult.stdout : "",
apmEnabled && apmResult.code === 0
? parseApmUpgradableOutput(apmResult.stdout)
: [];
logger.debug(
`[UpdateCenter] parsed aptssItems count=${aptssItems.length}`,
aptssItems.map((i) => `${i.pkgname} ${i.currentVersion}->${i.nextVersion}`),
);
logger.debug(
`[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 : "",
);
logger.debug(`[UpdateCenter] installedSources size=${installedSources.size}`);
const [categorizedAptssItems, categorizedApmItems] = await Promise.all([
enrichItemCategories(aptssItems),
enrichItemCategories(apmItems),
aptssAvailable ? enrichItemCategories(aptssItems) : Promise.resolve([]),
apmEnabled ? enrichItemCategories(apmItems) : Promise.resolve([]),
]);
const [enrichedAptssItems, enrichedApmItems] = await Promise.all([
enrichAptssItems(categorizedAptssItems, runCommand),
enrichApmItems(categorizedApmItems, runCommand),
aptssAvailable
? enrichAptssItems(categorizedAptssItems, runCommand)
: Promise.resolve({ items: [], warnings: [] }),
apmEnabled
? enrichApmItems(categorizedApmItems, runCommand)
: Promise.resolve({ items: [], warnings: [] }),
]);
logger.debug(
`[UpdateCenter] enrichedAptssItems: count=${enrichedAptssItems.items.length}, warnings=${enrichedAptssItems.warnings.length}`,
enrichedAptssItems.warnings,
);
logger.debug(
`[UpdateCenter] enrichedApmItems: count=${enrichedApmItems.items.length}, warnings=${enrichedApmItems.warnings.length}`,
enrichedApmItems.warnings,
);
return {
items: mergeUpdateSources(
const mergedItems = mergeUpdateSources(
enrichItemIcons(enrichedAptssItems.items),
enrichItemIcons(enrichedApmItems.items),
installedSources,
);
logger.debug(
`[UpdateCenter] mergedItems count=${mergedItems.length}`,
mergedItems.map(
(i) => `${i.pkgname} (${i.source}) ${i.currentVersion}->${i.nextVersion}`,
),
);
// 标记被系统锁定(apt-mark hold)的包:这类包被 apt 拒绝升级(除非 --allow-change-held-packages)。
// 更新中心据此默认禁用其勾选,并提示用户单独开启「强制安装」。
const heldItems = await markHeldPackages(mergedItems, runCommand);
return {
items: heldItems,
warnings: [
...warnings,
...enrichedAptssItems.warnings,
@@ -412,6 +519,167 @@ export const loadUpdateCenterItems = async (
};
};
// 通过 `apt-mark showhold` 获取系统锁定的包集合,给可升级项中匹配者打 held 标记。
// 仅 sparkaptss/deb)源受 apt hold 影响;apm 源不经过 apt,无需标记。
const markHeldPackages = async (
items: UpdateCenterItem[],
runCommand: UpdateCenterCommandRunner,
): Promise<UpdateCenterItem[]> => {
const sparkItems = items.filter((item) => item.source === "aptss");
if (sparkItems.length === 0) {
return items;
}
const result = await runCommand("apt-mark", ["showhold"]);
if (result.code !== 0) {
// 查询失败不阻断更新列表,仅跳过 hold 标记
console.warn(`[UpdateCenter] apt-mark showhold failed: ${result.stderr}`);
return items;
}
const heldSet = new Set(
result.stdout
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0),
);
if (heldSet.size === 0) {
return items;
}
return items.map((item) =>
item.source === "aptss" && heldSet.has(item.pkgname)
? { ...item, held: true }
: item,
);
};
// 子进程超时(毫秒):网络慢/镜像源卡死时,避免命令永久挂起
const SYSTEM_UPDATE_COMMAND_TIMEOUT_MS = 90_000;
// 带超时保护的命令执行:超时杀掉子进程并 resolve,防止调用方永久冻结
const runCommandWithTimeout = (
command: string,
args: string[],
): Promise<{ code: number; stdout: string; stderr: string }> =>
new Promise((resolve) => {
const child = spawn(command, args, { shell: false, env: process.env });
let stdout = "";
let stderr = "";
let settled = false;
const timer = setTimeout(() => {
if (settled) return;
settled = true;
try {
child.kill("SIGKILL");
} catch {
// 忽略杀进程异常
}
resolve({
code: -1,
stdout,
stderr: `${stderr}\n[timeout] command exceeded ${SYSTEM_UPDATE_COMMAND_TIMEOUT_MS}ms`,
});
}, SYSTEM_UPDATE_COMMAND_TIMEOUT_MS);
const finish = (result: {
code: number;
stdout: string;
stderr: string;
}): void => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(result);
};
child.stdout?.on("data", (data) => {
stdout += data.toString();
});
child.stderr?.on("data", (data) => {
stderr += data.toString();
});
child.on("error", (err) =>
finish({ code: -1, stdout, stderr: err.message }),
);
child.on("close", (code) => finish({ code: code ?? -1, stdout, stderr }));
});
// 刷新软件源(aptss ssupdate + apm update),提权执行。
// 供更新中心 IPC 与“启动后空闲预刷新”复用,单一逻辑来源。
export const runSystemUpdateSources = async (
storeFilter: StoreFilter = "both",
): Promise<{ aptss?: string; apm?: string }> => {
logger.debug(
`[UpdateCenter] runSystemUpdateSources called with storeFilter=${storeFilter}`,
);
const results: { aptss?: string; apm?: string } = {};
const isSourceEnabled = (
filter: StoreFilter,
source: "spark" | "apm",
): boolean => filter === "both" || filter === source;
if (isSourceEnabled(storeFilter, "spark")) {
const whichResult = await runCommandWithTimeout("which", ["aptss"]);
const aptssAvailable =
whichResult.code === 0 && whichResult.stdout.trim().length > 0;
if (aptssAvailable) {
logger.debug("[UpdateCenter] Running: pkexec shell-caller aptss ssupdate");
const superUserCmd = await findExecutable(
SUPER_USER_COMMAND_CANDIDATES[0],
);
if (superUserCmd) {
const result = await runCommandWithTimeout(superUserCmd, [
SHELL_CALLER_PATH,
"aptss",
"ssupdate",
]);
results.aptss =
result.code === 0
? "ok"
: `failed: ${result.stderr.substring(0, 200)}`;
logger.debug("[UpdateCenter] aptss ssupdate result:", results.aptss);
} else {
results.aptss = "failed: pkexec not found";
console.warn("[UpdateCenter] pkexec not found, skipping aptss update");
}
} else {
results.aptss = "skipped: aptss not installed";
}
}
if (isSourceEnabled(storeFilter, "apm")) {
const whichResult = await runCommandWithTimeout("which", ["apm"]);
const apmAvailable =
whichResult.code === 0 && whichResult.stdout.trim().length > 0;
if (apmAvailable) {
logger.debug("[UpdateCenter] Running: pkexec shell-caller apm update");
const superUserCmd = await findExecutable(
SUPER_USER_COMMAND_CANDIDATES[0],
);
if (superUserCmd) {
const result = await runCommandWithTimeout(superUserCmd, [
SHELL_CALLER_PATH,
"apm",
"update",
]);
results.apm =
result.code === 0
? "ok"
: `failed: ${result.stderr.substring(0, 200)}`;
logger.debug("[UpdateCenter] apm update result:", results.apm);
} else {
results.apm = "failed: pkexec not found";
console.warn("[UpdateCenter] pkexec not found, skipping apm update");
}
} else {
results.apm = "skipped: apm not installed";
}
}
return results;
};
export const registerUpdateCenterIpc = (
ipc: Pick<typeof ipcMain, "handle">,
service: Pick<
@@ -426,8 +694,20 @@ export const registerUpdateCenterIpc = (
| "subscribe"
>,
): void => {
ipc.handle("update-center-open", () => service.open());
ipc.handle("update-center-refresh", () => service.refresh());
ipc.handle(
"update-center-run-system-update",
async (_event, storeFilter: StoreFilter = "both") =>
runSystemUpdateSources(storeFilter),
);
ipc.handle(
"update-center-open",
(_event, storeFilter: StoreFilter = "both") => service.open(storeFilter),
);
ipc.handle(
"update-center-refresh",
(_event, storeFilter: StoreFilter = "both") => service.refresh(storeFilter),
);
ipc.handle(
"update-center-ignore",
(_event, payload: UpdateCenterIgnorePayload) => service.ignore(payload),
@@ -1,11 +1,16 @@
import { join } from "node:path";
import { tmpdir } from "node:os";
import { runAria2Download, type Aria2DownloadResult } from "./download";
import { installPackage } from "../shared-installer";
import type { UpdateCenterQueue, UpdateCenterTask } from "./queue";
import type { UpdateCenterItem } from "./types";
const DEFAULT_DOWNLOAD_ROOT = "/tmp/spark-store/update-center";
const DEFAULT_DOWNLOAD_ROOT = join(
tmpdir(),
`spark-store-${process.pid}`,
"update-center",
);
export interface InstallUpdateItemOptions {
item: UpdateCenterItem;
+51 -13
View File
@@ -6,10 +6,9 @@ import type {
UpdateSource,
} 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 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 epochMatch = version.match(/^(\d+):(.*)$/);
@@ -190,19 +189,32 @@ const parseUpgradableOutput = (
const items: UpdateCenterItem[] = [];
for (const line of output.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("Listing")) {
const trimmed = line
.replace(
// eslint-disable-next-line no-control-regex
/\x1b\[[0-9;]*m/g,
"",
)
.trim();
if (!trimmed || trimmed.startsWith("Listing") || !trimmed.includes("/")) {
continue;
}
const match = trimmed.match(UPGRADABLE_PATTERN);
if (!match) {
const tokens = trimmed.split(/\s+/);
if (tokens.length < 3) {
continue;
}
const [, pkgname, nextVersion, currentVersion] = match;
const arch = trimmed.split(/\s+/)[2];
if (!pkgname || nextVersion === currentVersion) {
const pkgname = tokens[0]?.split("/")[0] ?? "";
const nextVersion = tokens[1] ?? "";
const arch = tokens[2] ?? "";
const currentVersion =
trimmed.match(CURRENT_VERSION_PATTERN)?.[1] ?? tokens[5] ?? "";
// 仅当包名缺失或当前版本解析失败时才跳过。
// 注意:不再因 nextVersion === currentVersion 而跳过——aptss 已判定该项为
// upgradable,应信任上游判断;否则当仓库元数据出现"同版本重新发布"等情况时,
// 真实的更新项会被无声隐藏,导致"软件更新"列表空白。
if (!pkgname || !currentVersion) {
continue;
}
@@ -254,10 +266,29 @@ const compareVersions = (left: string, right: string): number => {
export const parseAptssUpgradableOutput = (
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[] =>
parseUpgradableOutput(output, "apm");
export const parseApmUpgradableOutput = (
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 = (
output: string,
@@ -265,8 +296,15 @@ export const parsePrintUrisOutput = (
UpdateCenterItem,
"downloadUrl" | "fileName" | "size" | "sha512"
> | 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) {
console.log(
`[UpdateCenter] parsePrintUrisOutput: no match found for pattern ${PRINT_URIS_PATTERN}`,
);
return null;
}
+94 -28
View File
@@ -1,10 +1,15 @@
import { BrowserWindow } from "electron";
import {
LEGACY_IGNORE_CONFIG_PATH,
addInstallTask,
type QueueInstallPayload,
} from "../install-manager";
import {
IGNORE_CONFIG_PATH,
applyIgnoredEntries,
createIgnoreKey,
loadIgnoredEntries,
saveIgnoredEntries,
sortIgnoredItems,
} from "./ignore-config";
import {
createUpdateCenterQueue,
@@ -12,6 +17,8 @@ import {
} from "./queue";
import type { UpdateCenterItem, UpdateSource } from "./types";
export type StoreFilter = "spark" | "apm" | "both";
export interface UpdateCenterLoadedItems {
items: UpdateCenterItem[];
warnings: string[];
@@ -64,11 +71,13 @@ export interface UpdateCenterIgnorePayload {
export interface UpdateCenterStartTask {
taskKey: string;
id: number;
// 强制安装被系统锁定(apt-mark hold)的包
forceHeld?: boolean;
}
export interface UpdateCenterService {
open: () => Promise<UpdateCenterServiceState>;
refresh: () => Promise<UpdateCenterServiceState>;
open: (storeFilter?: StoreFilter) => Promise<UpdateCenterServiceState>;
refresh: (storeFilter?: StoreFilter) => Promise<UpdateCenterServiceState>;
ignore: (payload: UpdateCenterIgnorePayload) => Promise<void>;
unignore: (payload: UpdateCenterIgnorePayload) => Promise<void>;
start: (tasks: UpdateCenterStartTask[]) => Promise<void>;
@@ -80,7 +89,9 @@ export interface UpdateCenterService {
}
export interface CreateUpdateCenterServiceOptions {
loadItems: () => Promise<UpdateCenterItem[] | UpdateCenterLoadedItems>;
loadItems: (
storeFilter: StoreFilter,
) => Promise<UpdateCenterItem[] | UpdateCenterLoadedItems>;
loadIgnoredEntries?: () => Promise<Set<string>>;
saveIgnoredEntries?: (entries: ReadonlySet<string>) => Promise<void>;
}
@@ -110,6 +121,7 @@ const toState = (
migrationSource: item.migrationSource,
migrationTarget: item.migrationTarget,
aptssVersion: item.aptssVersion,
held: item.held,
})),
tasks: [], // 不再展示任务日志
warnings: [...snapshot.warnings],
@@ -134,13 +146,14 @@ export const createUpdateCenterService = (
): UpdateCenterService => {
const queue = createUpdateCenterQueue();
const listeners = new Set<(snapshot: UpdateCenterServiceState) => void>();
let currentStoreFilter: StoreFilter = "both";
const loadIgnored =
options.loadIgnoredEntries ??
(() => loadIgnoredEntries(LEGACY_IGNORE_CONFIG_PATH));
(() => loadIgnoredEntries(IGNORE_CONFIG_PATH));
const saveIgnored =
options.saveIgnoredEntries ??
((entries: ReadonlySet<string>) =>
saveIgnoredEntries(LEGACY_IGNORE_CONFIG_PATH, entries));
saveIgnoredEntries(IGNORE_CONFIG_PATH, entries));
const applyWarning = (message: string): void => {
queue.finishRefresh([message]);
@@ -156,19 +169,38 @@ export const createUpdateCenterService = (
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();
emit();
try {
const ignoredEntries = await loadIgnored();
const loadedItems = normalizeLoadedItems(await options.loadItems());
const items = applyIgnoredEntries(loadedItems.items, ignoredEntries);
console.log(`[UpdateCenter] ignoredEntries count=${ignoredEntries.size}`);
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.finishRefresh(loadedItems.warnings);
return emit();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`[UpdateCenter] refresh error:`, error);
queue.setItems([]);
applyWarning(message);
return emit();
@@ -192,15 +224,10 @@ export const createUpdateCenterService = (
},
async start(tasks) {
const snapshot = queue.getSnapshot();
const taskIdByKey = new Map(tasks.map((task) => [task.taskKey, task.id]));
const selectedItems = snapshot.items.filter(
(item) => taskIdByKey.has(getTaskKey(item)) && !item.ignored,
const taskByKey = new Map(
tasks.map((task) => [task.taskKey, task] as const),
);
if (selectedItems.length === 0) {
return;
}
// 获取主窗口的 webContents
const mainWindow = BrowserWindow.getAllWindows()[0];
const webContents = mainWindow?.webContents;
@@ -210,22 +237,63 @@ export const createUpdateCenterService = (
return;
}
// 获取当前 items
let currentItems = snapshot.items;
// 分类:可启动项 vs 被锁定(held)且未开启强制安装的项。
// 被锁定的项需用户单独开启「强制安装」才能升级,否则明确告知失败,避免静默卡在「开始更新」。
const startableItems: typeof snapshot.items = [];
const heldBlocked: Array<{
item: (typeof snapshot.items)[number];
id: number;
}> = [];
for (const item of selectedItems) {
const updateTaskId = taskIdByKey.get(getTaskKey(item));
if (!updateTaskId) {
for (const item of snapshot.items) {
const updateTask = taskByKey.get(getTaskKey(item));
if (!updateTask || item.ignored) continue;
if (item.held === true && !updateTask.forceHeld) {
heldBlocked.push({ item, id: updateTask.id });
continue;
}
startableItems.push(item);
}
// 对「被锁定未强制」的选中项,向前端发送明确失败通知(而非静默跳过)
for (const blocked of heldBlocked) {
webContents.send("install-complete", {
id: blocked.id,
success: false,
time: Date.now(),
exitCode: -1,
message: JSON.stringify({
message: `软件包 ${blocked.item.pkgname} 被系统锁定(hold),已在更新中心默认跳过。如需升级,请在该软件行开启「强制安装」开关后重试。`,
stdout: "",
stderr: "",
}),
});
}
if (startableItems.length === 0) {
return;
}
// 获取当前 items 的副本,启动成功后从更新中心列表移除已交出的项,
// 避免同一包同时出现在更新中心与下载队列(更新中心只展示待更新的项)。
let currentItems = snapshot.items;
for (const item of startableItems) {
const updateTask = taskByKey.get(getTaskKey(item));
if (!updateTask) {
continue;
}
const { id: updateTaskId, forceHeld } = updateTask;
// 构建 metalink URL
const metalinkUrl = item.downloadUrl
? `${item.downloadUrl}.metalink`
: undefined;
// 发送到主下载队列
const installTaskData = {
// 直接加入主下载队列(之前用 webContents.send("queue-install") 只会发给渲染端,
// 主进程 ipcMain 监听不到自己发出的 send,导致任务实际未启动而卡死)。
const installTaskData: QueueInstallPayload = {
id: updateTaskId,
pkgname: item.pkgname,
metalinkUrl,
@@ -233,20 +301,18 @@ export const createUpdateCenterService = (
upgradeOnly: true,
origin: item.source === "apm" ? "apm" : "spark",
retry: false,
forceHeld: item.held === true ? forceHeld : false,
};
// 通过 IPC 发送到主下载队列
webContents.send("queue-install", JSON.stringify(installTaskData));
await addInstallTask(installTaskData, webContents);
// 从更新中心的 items 中移除该应用(不再显示在更新列表中)
// 启动成功后从更新中心列表移除该项(被 hold 未强制拦截的项不会进入此处,仍保留)。
currentItems = currentItems.filter(
(i) => getTaskKey(i) !== getTaskKey(item),
);
}
// 更新队列中的 items
queue.setItems(currentItems);
emit();
},
async cancel(taskKey) {
@@ -24,4 +24,8 @@ export interface UpdateCenterItem {
migrationSource?: UpdateSource;
migrationTarget?: UpdateSource;
aptssVersion?: string;
// 更新发布时间(毫秒时间戳);由上游解析填充,暂无则缺省
updateTime?: number;
// 是否被系统锁定(apt-mark hold)。被锁定的包默认不可批量选中,需用户单独开启「强制安装」
held?: boolean;
}
+18 -1
View File
@@ -45,7 +45,7 @@ class ListenersMap {
}
}
const protocols = ["spk"];
const protocols = ["spk", "apt"];
const listeners = new ListenersMap();
export const deepLink = {
@@ -81,6 +81,23 @@ export function handleCommandLine(commandLine: string[]) {
try {
const url = new URL(target);
// Handle apt:// protocol: convert to spk://search/pkgname
if (url.protocol === "apt:") {
// Format: apt://pkgname
const pkgname =
url.hostname || url.pathname.split("/").filter(Boolean)[0];
if (pkgname) {
const query: Query = { pkgname };
logger.info(`Deep link: apt protocol converted to search: ${pkgname}`);
listeners.emit("search", query);
} else {
logger.warn(
`Deep link: invalid apt format, expected //pkgname, got ${target}`,
);
}
return;
}
const action = url.hostname; // 'search'
logger.info(`Deep link: action found: ${action}`);
+575 -35
View File
@@ -4,9 +4,11 @@ import {
ipcMain,
Menu,
nativeImage,
net,
shell,
Tray,
nativeTheme,
screen,
session,
} from "electron";
import { fileURLToPath } from "node:url";
@@ -18,11 +20,15 @@ import { handleCommandLine } from "./deeplink.js";
import { isLoaded } from "../global.js";
import { tasks } from "./backend/install-manager.js";
import { sendTelemetryOnce } from "./backend/telemetry.js";
import { initializeUpdateCenter } from "./backend/update-center/index.js";
import {
initializeUpdateCenter,
runSystemUpdateSources,
} from "./backend/update-center/index.js";
import {
getMainWindowCloseAction,
type MainWindowCloseGuardState,
} from "./window-close-guard.js";
import { registerSubmitterHandlers } from "./backend/submitter.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
process.env.APP_ROOT = path.join(__dirname, "../..");
@@ -40,6 +46,23 @@ function getAppVersion(): string {
}
}
function getSystemInfo(): { distro: string } {
try {
const raw = fs.readFileSync("/etc/os-release", "utf8");
const fields = Object.fromEntries(
raw
.split("\n")
.map((line) => line.match(/^([A-Z_]+)=(.*)$/))
.filter((match): match is RegExpMatchArray => match !== null)
.map((match) => [match[1], match[2].replace(/^"|"$/g, "")]),
);
const distro = fields.PRETTY_NAME || fields.NAME || "unknown";
return { distro };
} catch {
return { distro: "unknown" };
}
}
// 处理 --version 参数(在单实例检查之前)
if (process.argv.includes("--version") || process.argv.includes("-v")) {
console.log(getAppVersion());
@@ -54,7 +77,15 @@ if (!app.requestSingleInstanceLock()) {
import "./backend/install-manager.js";
import "./handle-url-scheme.js";
// 关闭 Linux 的覆盖式(overlay)滚动条,强制使用经典滚动条,
// 否则 GTK overlay 滚动条会忽略渲染进程的 ::-webkit-scrollbar 颜色,
// 导致暗色模式下滚动条始终是原生灰色。必须在 app ready 前设置。
if (process.platform === "linux") {
app.commandLine.appendSwitch("disable-features", "OverlayScrollbar");
}
const logger = pino({ name: "index.ts" });
const FLARUM_TOKEN_URL = "https://bbs.spark-app.store/api/token";
// The built directory structure
//
@@ -70,6 +101,10 @@ export const MAIN_DIST = path.join(process.env.APP_ROOT, "dist-electron");
export const RENDERER_DIST = path.join(process.env.APP_ROOT, "dist");
export const VITE_DEV_SERVER_URL = process.env.VITE_DEV_SERVER_URL;
// 进程专属临时目录,避免多实例/残留进程互相影响
// 退出时由 will-quit 统一清理
export const TEMP_BASE = path.join(os.tmpdir(), `spark-store-${process.pid}`);
process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL
? path.join(process.env.APP_ROOT, "public")
: RENDERER_DIST;
@@ -94,10 +129,27 @@ const getUserAgent = (): string => {
return `Spark-Store/${getAppVersion()}`;
};
let submitterWin: BrowserWindow | null = null;
registerSubmitterHandlers(
preload,
indexHtml,
VITE_DEV_SERVER_URL,
() => submitterWin,
(w) => {
submitterWin = w;
},
);
logger.info("User Agent: " + getUserAgent());
/** 根据启动参数 --no-apm / --no-spark 决定只展示的来源 */
function getStoreFilterFromArgv(): "spark" | "apm" | "both" {
if (process.arch === "loong64") {
// Currently loong64 only have spark support,
// 但用户显式传入 --no-spark 时应允许回退到 apm
if (process.argv.includes("--no-spark")) return "apm";
return "spark";
} else {
const argv = process.argv;
const noApm = argv.includes("--no-apm");
const noSpark = argv.includes("--no-spark");
@@ -106,12 +158,101 @@ function getStoreFilterFromArgv(): "spark" | "apm" | "both" {
if (noSpark) return "apm";
return "both";
}
}
ipcMain.handle("get-store-filter", (): "spark" | "apm" | "both" =>
getStoreFilterFromArgv(),
);
// 渲染端在窗口尺寸变化时(包括无边框窗口鼠标拉边角)经此保存当前窗口尺寸
ipcMain.handle("save-window-bounds", (): boolean => {
if (win && !win.isDestroyed()) scheduleSaveBounds(win);
return true;
});
// 渲染端(设置页)切换界面整体缩放系数,经此实时应用到主窗口。
// factor 限定在 0.5~3 之间,避免极端值导致界面不可用。
ipcMain.handle("set-zoom-factor", (_event, factor: unknown): boolean => {
if (typeof factor !== "number" || !Number.isFinite(factor)) return false;
const clamped = Math.min(3, Math.max(0.5, factor));
if (win && !win.isDestroyed()) {
win.webContents.setZoomFactor(clamped);
return true;
}
return false;
});
ipcMain.handle("get-app-version", (): string => getAppVersion());
ipcMain.handle("get-system-info", (): { distro: string } => getSystemInfo());
ipcMain.handle("request-flarum-token", async (_event, payload: unknown) => {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
throw new Error("登录信息格式不正确,请重新输入。");
}
const credentials = payload as Record<string, unknown>;
if (
typeof credentials.identification !== "string" ||
typeof credentials.password !== "string"
) {
throw new Error("登录信息格式不正确,请重新输入。");
}
logger.info({ endpoint: FLARUM_TOKEN_URL }, "Requesting Flarum login token");
let response: Response;
try {
response = await fetch(FLARUM_TOKEN_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
"User-Agent": getUserAgent(),
},
body: JSON.stringify({
identification: credentials.identification,
password: credentials.password,
}),
});
} catch (err) {
logger.error(
{ err, endpoint: FLARUM_TOKEN_URL },
"Flarum token request failed before response",
);
throw new Error("无法连接星火论坛,请检查网络后重试。");
}
if (!response.ok) {
logger.warn(
{ endpoint: FLARUM_TOKEN_URL, status: response.status },
"Flarum rejected login token request",
);
throw new Error("论坛登录失败,请检查账号和密码。");
}
const data = (await response.json()) as Record<string, unknown>;
const userId = data.userId ?? data.user_id;
if (
typeof data.token !== "string" ||
userId === undefined ||
userId === null
) {
logger.warn(
{
endpoint: FLARUM_TOKEN_URL,
hasToken: typeof data.token === "string" && data.token.length > 0,
hasUserId: userId !== undefined && userId !== null,
},
"Flarum token response missing required fields",
);
throw new Error("论坛登录响应异常,请稍后重试。");
}
return {
token: data.token,
userId: String(userId),
};
});
const getMainWindowCloseGuardState = (): MainWindowCloseGuardState => ({
installTaskCount: tasks.size,
@@ -151,11 +292,160 @@ const requestApplicationExit = (): void => {
app.quit();
};
const showAndFocusMainWindow = async (): Promise<void> => {
if (!win || win.isDestroyed()) {
// 等待窗口创建完成,创建失败时调用方可通过异常感知
await createWindow();
return;
}
if (win.isMinimized()) {
win.restore();
}
win.show();
win.setSkipTaskbar(false);
win.focus();
};
// 窗口尺寸持久化:保存/恢复上一次调整后的窗口大小,避免每次打开都使用默认尺寸
const DEFAULT_WINDOW_SIZE = { width: 1366, height: 768 };
const MIN_WINDOW_SIZE = { width: 800, height: 500 };
// 超过该尺寸的窗口(通常为全屏/最大化状态)在恢复时回退到默认尺寸,避免「启动即全屏、还原按钮失效」
const OVERSIZED_WINDOW_THRESHOLD = { width: 1600, height: 900 };
interface WindowState {
width?: number;
height?: number;
x?: number;
y?: number;
maximized?: boolean;
/** 界面整体缩放系数(Electron webContents.setZoomFactor),默认 1 */
zoomFactor?: number;
}
function getWindowStatePath(): string {
// 延迟到调用时再取 userData,避免在 app ready 之前调用 app.getPath 出错
return path.join(app.getPath("userData"), "window-state.json");
}
// 校验保存的窗口位置是否至少部分落在某个显示器可见区域内,避免窗口跑到屏幕外
function isVisible(bounds: WindowState): boolean {
// 解构为局部常量后,控制流收窄(const 不可变)可穿透到下方嵌套闭包,
// 消除 x/y/width/height 的 “可能为未定义” 告警
const { x, y, width, height } = bounds;
if (
x === undefined ||
y === undefined ||
width === undefined ||
height === undefined
) {
return false;
}
const displays = screen.getAllDisplays();
return displays.some((display) => {
const w = display.workArea;
const horizontally = x < w.x + w.width && x + width > w.x;
const vertically = y < w.y + w.height && y + height > w.y;
return horizontally && vertically;
});
}
function loadWindowState(): WindowState {
try {
const file = getWindowStatePath();
if (fs.existsSync(file)) {
const parsed = JSON.parse(fs.readFileSync(file, "utf-8")) as WindowState;
if (
parsed.width !== undefined &&
parsed.height !== undefined &&
parsed.width >= MIN_WINDOW_SIZE.width &&
parsed.height >= MIN_WINDOW_SIZE.height &&
isVisible(parsed)
) {
return parsed;
}
logger.warn({ parsed }, "已保存的窗口状态无效,使用默认尺寸");
}
} catch (err) {
logger.warn({ err }, "读取窗口状态失败,使用默认尺寸");
}
return {};
}
function saveWindowState(state: WindowState): void {
try {
fs.writeFileSync(getWindowStatePath(), JSON.stringify(state));
logger.info({ state }, "已保存窗口状态");
} catch (err) {
logger.warn({ err }, "保存窗口状态失败");
}
}
let saveBoundsTimer: NodeJS.Timeout | null = null;
function flushSaveBounds(): void {
if (saveBoundsTimer) {
clearTimeout(saveBoundsTimer);
saveBoundsTimer = null;
}
if (win && !win.isDestroyed()) {
const { width, height, x, y } = win.getBounds();
saveWindowState({
width,
height,
x,
y,
maximized: win.isMaximized(),
zoomFactor: win.webContents.getZoomFactor(),
});
}
}
function scheduleSaveBounds(winInstance: BrowserWindow): void {
if (saveBoundsTimer) clearTimeout(saveBoundsTimer);
saveBoundsTimer = setTimeout(() => {
if (winInstance.isDestroyed()) return;
const { width, height, x, y } = winInstance.getBounds();
saveWindowState({
width,
height,
x,
y,
maximized: winInstance.isMaximized(),
zoomFactor: winInstance.webContents.getZoomFactor(),
});
}, 400);
}
// 应用退出前立即持久化(防抖 400ms 可能在快速关闭时丢失最后一次状态)
app.on("before-quit", () => {
flushSaveBounds();
});
async function createWindow() {
win = new BrowserWindow({
const saved = loadWindowState();
// 恢复时:若上次窗口过大(>1600x900,通常为全屏/最大化),回退到默认尺寸并居中,
// 避免「启动即全屏、还原按钮失效」;其余情况保留上次记录的实际尺寸(并居中)。
// 放大/还原仍交由标题栏按钮控制。
const oversized =
(saved.width ?? 0) > OVERSIZED_WINDOW_THRESHOLD.width ||
(saved.height ?? 0) > OVERSIZED_WINDOW_THRESHOLD.height;
const restoredWidth = oversized
? DEFAULT_WINDOW_SIZE.width
: Math.max(saved.width ?? DEFAULT_WINDOW_SIZE.width, MIN_WINDOW_SIZE.width);
const restoredHeight = oversized
? DEFAULT_WINDOW_SIZE.height
: Math.max(
saved.height ?? DEFAULT_WINDOW_SIZE.height,
MIN_WINDOW_SIZE.height,
);
const mainWindow = new BrowserWindow({
title: "星火应用商店",
width: 1366,
height: 768,
width: restoredWidth,
height: restoredHeight,
center: true,
minWidth: MIN_WINDOW_SIZE.width,
minHeight: MIN_WINDOW_SIZE.height,
frame: false,
autoHideMenuBar: true,
icon: path.join(process.env.VITE_PUBLIC, "favicon.ico"),
webPreferences: {
@@ -168,31 +458,97 @@ async function createWindow() {
// contextIsolation: false,
},
});
win = mainWindow;
// 启动即应用持久化的界面缩放系数(默认 1 = 100%)。
// 必须在 loadURL/loadFile 前设置,使首帧即按目标缩放渲染,避免闪烁。
const savedZoom =
typeof saved.zoomFactor === "number" &&
saved.zoomFactor >= 0.5 &&
saved.zoomFactor <= 3
? saved.zoomFactor
: 1;
mainWindow.webContents.setZoomFactor(savedZoom);
logger.info({ zoomFactor: savedZoom }, "已应用界面缩放系数");
// 设计意图(非缺陷,勿改为自动恢复 maximized):
// 启动时不自动恢复最大化状态。原因——最大化窗口在多数屏幕上 bounds 会超过
// OVERSIZED_WINDOW_THRESHOLD(1600x900),若强行恢复最大化会导致「启动即全屏、
// 还原按钮失效」的体验问题;故最大化/全屏状态在关闭后统一回退为默认尺寸并居中,
// 放大/还原交由标题栏按钮由用户主动控制。WindowState 仍保存 maximized 字段
// (供需要该信息的场景读取),但恢复阶段有意不调用 win.maximize()。
logger.info(
{ saved, restoredWidth, restoredHeight, oversized },
"已恢复窗口状态(过大窗口回退默认尺寸并居中;最大化状态有意不自动恢复)",
);
// 窗口大小/位置/最大化变化后防抖保存,下次启动时恢复
// 位置/最大化变化由主进程事件保存;尺寸变化由渲染端 DOM resize 经 IPC 兜底保存
mainWindow.on("moved", () => scheduleSaveBounds(mainWindow));
mainWindow.on("maximize", () => scheduleSaveBounds(mainWindow));
mainWindow.on("unmaximize", () => scheduleSaveBounds(mainWindow));
if (VITE_DEV_SERVER_URL) {
// #298
win.loadURL(VITE_DEV_SERVER_URL);
mainWindow.loadURL(VITE_DEV_SERVER_URL);
// Open devTool if the app is not packaged
win.webContents.openDevTools({ mode: "detach" });
mainWindow.webContents.openDevTools({ mode: "detach" });
} else {
win.loadFile(indexHtml);
mainWindow.loadFile(indexHtml);
}
// Test actively push message to the Electron-Renderer
win.webContents.on("did-finish-load", () => {
win?.webContents.send("main-process-message", new Date().toLocaleString());
mainWindow.webContents.on("did-finish-load", () => {
mainWindow.webContents.send(
"main-process-message",
new Date().toLocaleString(),
);
logger.info("Renderer process is ready.");
});
// Make all links open with the browser, not with the application
win.webContents.setWindowOpenHandler(({ url }) => {
if (url.startsWith("https:")) shell.openExternal(url);
// 仅允许可信域名的 https 链接通过浏览器打开,避免钓鱼/恶意站。
// 协议前缀 + 域名后缀白名单双重校验;非法/无效 URL 一律拒绝。
const ALLOWED_EXTERNAL_HOSTS = [
"spark-app.store",
"gitee.com",
"bbs.spark-app.store",
"spark-app.cn",
];
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
try {
const parsed = new URL(url);
if (
parsed.protocol === "https:" &&
ALLOWED_EXTERNAL_HOSTS.some((h) => parsed.hostname === h || parsed.hostname.endsWith(`.${h}`))
) {
shell.openExternal(url);
}
} catch {
// 无效 URL,拒绝打开
}
return { action: "deny" };
});
// win.webContents.on('will-navigate', (event, url) => { }) #344
win.on("close", (event) => {
mainWindow.on("closed", () => {
if (win === mainWindow) {
win = null;
}
});
mainWindow.on("close", (event) => {
if (allowAppExit) {
// 真正退出前同步保存最终窗口尺寸(防抖可能尚未触发)
// 先清除尚未触发的防抖定时器,避免旧定时器随后覆盖本次同步写入
if (saveBoundsTimer) clearTimeout(saveBoundsTimer);
const { width, height, x, y } = mainWindow.getBounds();
saveWindowState({
width,
height,
x,
y,
maximized: mainWindow.isMaximized(),
});
return;
}
@@ -214,29 +570,155 @@ ipcMain.on("set-theme-source", (event, theme: "system" | "light" | "dark") => {
nativeTheme.themeSource = theme;
});
// 启动安装设置脚本(可能需要提升权限)
ipcMain.handle("open-install-settings", async () => {
ipcMain.on("window-control-minimize", () => {
win?.minimize();
});
ipcMain.on("window-control-toggle-maximize", () => {
if (!win) {
return;
}
if (win.isMaximized()) {
win.unmaximize();
return;
}
win.maximize();
});
ipcMain.on("window-control-close", () => {
win?.close();
});
// 配置文件路径
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 {
const { spawn } = await import("node:child_process");
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], {
detached: true,
stdio: "ignore",
});
child.unref();
logger.info(`Launched ${scriptPath}`);
logger.info(`Launched update check script: ${scriptPath}`);
return { success: true };
} 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) };
}
});
// 启动投稿器窗口
// Register custom protocol handlers
if (process.defaultApp) {
if (process.argv.length >= 2) {
app.setAsDefaultProtocolClient("spk", process.execPath, [
path.resolve(process.argv[1]),
]);
app.setAsDefaultProtocolClient("apt", process.execPath, [
path.resolve(process.argv[1]),
]);
}
} else {
app.setAsDefaultProtocolClient("spk");
app.setAsDefaultProtocolClient("apt");
}
app.whenReady().then(() => {
// Set User-Agent for client
session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => {
details.requestHeaders["User-Agent"] = getUserAgent();
// 数据 JSONapplist / categories / priority-config / sidebar-config 等)禁用客户端缓存。
// nginx 默认不发 Cache-ControlChromium 会按启发式 TTL(≈(now-LastModified)/10)复用陈旧副本,
// 导致服务端上新应用后商店内仍搜不到、且重启无效(仅删 Cache 目录才生效)。
// 注意:C2 会给 URL 追加 ?_t=... 版本戳,故需按 query 前的 pathname 判断,而非 endsWith(".json")。
const dataUrlPath = details.url.split("?")[0];
if (dataUrlPath.endsWith(".json")) {
details.requestHeaders["Cache-Control"] = "no-cache";
details.requestHeaders["Pragma"] = "no-cache";
}
callback({ cancel: false, requestHeaders: details.requestHeaders });
});
createWindow();
@@ -244,7 +726,67 @@ app.whenReady().then(() => {
initializeUpdateCenter();
// 启动后执行一次遥测(仅 Linux,不阻塞)
sendTelemetryOnce(getAppVersion());
// 注册“渲染进程首页加载完成”信号:收到后立即开始后台刷新软件源,
// 趁系统负载不高时提前刷新 aptss/apm 源,用户稍后打开“软件更新”即可秒出。
// 同时保留一个兜底定时器,防止渲染进程未发信号时完全不刷新。
ipcMain.on("update-center-trigger-prefetch", () => {
startSourcePreRefreshOnce();
});
setTimeout(startSourcePreRefreshOnce, PRE_REFRESH_FALLBACK_MS);
});
// 启动后空闲预刷新软件源(带重试),不阻塞启动流程
// 与 src/modules/updateCenter.ts 的 backgroundRefresh 重试为【对称设计】,非代码遗漏:
// 主进程负责“启动预热”,前端负责“打开兜底”,
// 两者进程/守卫/调用目标不同,故各自保留一份,勿抽共享。
const PRE_REFRESH_BACKOFF_MS = [2000, 4000, 8000];
const PRE_REFRESH_MAX_RETRIES = 3;
const PRE_REFRESH_FALLBACK_MS = 15_000; // 渲染信号未到达时的兜底,15s 后也跑
const PRE_REFRESH_TIMEOUT_MS = 60_000; // 单次预刷新整体超时,避免 pkexec 卡死挂起
let preRefreshStarted = false;
// 按重试次数取退避毫秒(越界时回退到最大间隔)
const getPreRefreshBackoffDelay = (attempt: number): number =>
PRE_REFRESH_BACKOFF_MS[attempt - 1] ??
PRE_REFRESH_BACKOFF_MS[PRE_REFRESH_BACKOFF_MS.length - 1];
// 确保预刷新只触发一次(渲染信号或兜底定时器 whichever first)。
// 设计意图:单次会话仅预热一次(preRefreshStarted 置 true 后不再重置),
// 避免用户在更新中心 Tab 间快速切换时重复触发 pkexec 弹窗造成困惑。
const startSourcePreRefreshOnce = (attempt = 1): void => {
if (preRefreshStarted) return;
preRefreshStarted = true;
const run = (): void => {
// 网络可用性前置检查:离线时不发起提权刷新(pkexec 弹窗无意义且困惑)
if (!net.isOnline()) {
logger.info("[UpdateCenter] 预刷新跳过:当前离线");
return;
}
const timeoutPromise = new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error("pre-refresh timeout")),
PRE_REFRESH_TIMEOUT_MS,
),
);
Promise.race([runSystemUpdateSources("both"), timeoutPromise])
.then((results) => {
console.log("[UpdateCenter] pre-refresh done:", results);
})
.catch((error) => {
console.warn("[UpdateCenter] pre-refresh failed:", error);
if (attempt < PRE_REFRESH_MAX_RETRIES) {
const delay = getPreRefreshBackoffDelay(attempt);
setTimeout(() => startSourcePreRefreshOnce(attempt + 1), delay);
}
});
};
run();
};
app.on("window-all-closed", () => {
win = null;
@@ -253,26 +795,17 @@ app.on("window-all-closed", () => {
});
app.on("second-instance", () => {
if (win) {
// Focus on the main window if the user tried to open another
if (win.isMinimized()) win.restore();
win.focus();
}
void showAndFocusMainWindow();
});
app.on("activate", () => {
const allWindows = BrowserWindow.getAllWindows();
if (allWindows.length) {
allWindows[0].focus();
} else {
createWindow();
}
void showAndFocusMainWindow();
});
app.on("will-quit", () => {
// Clean up temp dir
// 清理本进程专属临时目录(PID 隔离,不影响其他实例)
logger.info("Cleaning up temp dir");
fs.rmSync("/tmp/spark-store/", { recursive: true, force: true });
fs.rmSync(TEMP_BASE, { recursive: true, force: true });
logger.info("Done, exiting");
});
@@ -319,7 +852,7 @@ app.whenReady().then(() => {
{
label: "显示主界面",
click: () => {
win.show();
void showAndFocusMainWindow();
},
},
{
@@ -334,16 +867,23 @@ app.whenReady().then(() => {
// 双击触发
tray.on("click", () => {
// 双击通知区图标实现应用的显示或隐藏
if (win.isVisible()) {
if (win && !win.isDestroyed() && win.isVisible()) {
win.hide();
win.setSkipTaskbar(true);
} else {
win.show();
win.setSkipTaskbar(false);
void showAndFocusMainWindow();
}
});
});
ipcMain.handle('window:state', () => {
const win = BrowserWindow.getFocusedWindow()
if (!win) return 'normal'
if (win.isMaximized()) return 'maximized'
if (win.isMinimized()) return 'minimized'
return 'normal'
})
// New window example arg: new windows url
// ipcMain.handle('open-win', (_, arg) => {
// const childWindow = new BrowserWindow({
+30 -5
View File
@@ -1,4 +1,11 @@
import { ipcRenderer, contextBridge, type IpcRendererEvent } from "electron";
import {
ipcRenderer,
contextBridge,
webUtils,
type IpcRendererEvent,
} from "electron";
type StoreFilter = "spark" | "apm" | "both";
type UpdateCenterSnapshot = {
items: Array<{
@@ -40,6 +47,13 @@ type IpcRendererFacade = {
invoke: typeof ipcRenderer.invoke;
};
type WindowControlBridge = {
minimize: () => void;
toggleMaximize: () => void;
close: () => void;
state: () => Promise<| 'normal' | 'maximized' | 'minimized'>;
};
type UpdateCenterStateListener = (snapshot: UpdateCenterSnapshot) => void;
type UpdateCenterStartTask = {
taskKey: string;
@@ -89,11 +103,22 @@ contextBridge.exposeInMainWorld("apm_store", {
})(),
});
contextBridge.exposeInMainWorld("electronUtils", {
getPathForFile: (file: File): string => webUtils.getPathForFile(file),
});
contextBridge.exposeInMainWorld("windowControls", {
minimize: () => ipcRenderer.send("window-control-minimize"),
toggleMaximize: () => ipcRenderer.send("window-control-toggle-maximize"),
close: () => ipcRenderer.send("window-control-close"),
state: () => ipcRenderer.invoke("window:state"),
} satisfies WindowControlBridge);
contextBridge.exposeInMainWorld("updateCenter", {
open: (): Promise<UpdateCenterSnapshot> =>
ipcRenderer.invoke("update-center-open"),
refresh: (): Promise<UpdateCenterSnapshot> =>
ipcRenderer.invoke("update-center-refresh"),
open: (storeFilter: StoreFilter = "both"): Promise<UpdateCenterSnapshot> =>
ipcRenderer.invoke("update-center-open", storeFilter),
refresh: (storeFilter: StoreFilter = "both"): Promise<UpdateCenterSnapshot> =>
ipcRenderer.invoke("update-center-refresh", storeFilter),
ignore: (payload: {
packageName: string;
newVersion: string;
+4 -41
View File
@@ -1,43 +1,6 @@
#!/bin/bash
readonly ACE_ENVIRONMENTS=(
"bookworm-run:amber-ce-bookworm"
"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环境中
# 检查包是否已安装
# 返回 0 表示已安装,非 0 表示未安装
for ace_entry in "${ACE_ENVIRONMENTS[@]}"; do
ace_cmd=${ace_entry%%:*}
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"
dpkg -s "$1" 2>/dev/null | grep -q 'Status: install ok installed'
exit $?
+30 -1
View File
@@ -85,6 +85,32 @@ case "$command_type" in
fi
;;
"force-ssinstall")
# 强制安装被系统锁定(apt-mark hold)的包:在一次已提权的 pkexec 会话内
# 完成「解除锁定 → ssinstall 本地 .deb → 恢复锁定」,避免单独 pkexec apt-mark
# 触发额外权限框(apt-mark 不在 policykit 免密 exec.path 内)。
# 用法:force-ssinstall <pkgname> <deb路径> [ssinstall 额外参数...]
pkg="$2"
deb="$3"
shift 3
# 仅放行单包名(与前端 PKGNAME_PATTERN 一致,双引号防注入)
if [[ -z "$pkg" || -z "$deb" ]]; then
echo "错误:force-ssinstall 缺少包名或 deb 路径参数。"
exit 1
fi
# 安装前解除系统锁定(失败仅警告,不阻断)
apt-mark unhold "$pkg" 2>&1 || echo "警告:解除系统锁定 $pkg 失败,将继续尝试安装"
# 安装本地 .deb(--native 由本分支统一追加)
/usr/bin/ssinstall "$deb" "$@" --native 2>&1
exit_code=$?
# 安装后无论如何恢复系统锁定,保持用户原本的 hold 状态
apt-mark hold "$pkg" 2>&1 || echo "警告:恢复系统锁定 $pkg 失败,请手动检查"
if [[ "$exit_code" != "0" ]]; then
echo "安装失败,可尝试安装对应的 APM 版本应用;若无对应的 APM 版本应用,可提交用户反馈"
fi
exit $exit_code
;;
"aptss")
# 针对 aptss 的特殊逻辑:如果是 remove 子命令,需要图形化确认
if [[ "$2" == "remove" ]]; then
@@ -155,7 +181,10 @@ case "$command_type" in
echo "操作已取消"
exit 0
fi
elif [[ "$2" == "ssupdate" ]]; then
/usr/bin/aptss "${@:2}" -y 2>&1
exit_code=$?
exit $exit_code
else
# 非 remove/install 命令,拒绝执行
echo "拒绝执行 aptss 白名单外的指令"
+158
View File
@@ -0,0 +1,158 @@
{
lib,
buildNpmPackage,
importNpmLock,
electron,
makeWrapper,
aria2,
apm,
coreutils,
gnugrep,
which,
xdg-utils,
bash,
}:
buildNpmPackage rec {
pname = "spark-store";
version = "5.1.1";
src = lib.cleanSourceWith {
src = ../.;
filter =
path: type:
let
baseName = baseNameOf path;
in
!(lib.elem baseName [
".git"
"dist"
"dist-electron"
"node_modules"
"release"
"result"
]);
};
npmDeps = importNpmLock {
npmRoot = ../.;
};
npmConfigHook = importNpmLock.npmConfigHook;
nativeBuildInputs = [
makeWrapper
];
env = {
ELECTRON_SKIP_BINARY_DOWNLOAD = "1";
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "1";
};
buildPhase = ''
runHook preBuild
npm run build:vite
runHook postBuild
'';
installPhase = ''
runHook preInstall
appDir="$out/share/spark-store"
npm prune --omit=dev --ignore-scripts
substituteInPlace extras/shell-caller.sh \
--replace-fail "/usr/bin/apm" "${lib.getExe' apm "apm"}"
mkdir -p "$appDir" "$out/bin"
cp -r dist dist-electron package.json node_modules extras icons "$appDir"/
chmod -R u+w "$appDir"
find "$appDir/extras" -type f -exec chmod +x {} \;
substituteInPlace "$appDir/dist-electron/main/index.js" \
--replace-fail "/opt/spark-store/extras/shell-caller.sh" "$appDir/extras/shell-caller.sh" \
--replace-fail "/opt/spark-store/extras/app-launcher" "$appDir/extras/app-launcher"
install -Dm644 pkg/usr/share/applications/spark-store.desktop \
"$out/share/applications/spark-store.desktop"
install -Dm644 icons/spark-store.svg \
"$out/share/icons/hicolor/scalable/apps/spark-store.svg"
install -Dm644 icons/spark-store.png \
"$out/share/icons/hicolor/512x512/apps/spark-store.png"
install -Dm644 extras/store.spark-app.spark-store.policy \
"$out/share/polkit-1/actions/store.spark-app.spark-store.policy"
substituteInPlace "$out/share/polkit-1/actions/store.spark-app.spark-store.policy" \
--replace-fail "/opt/spark-store/extras/shell-caller.sh" "$appDir/extras/shell-caller.sh"
cat > "$out/bin/spark-store" <<EOF
#!${bash}/bin/bash
export PATH="${lib.makeBinPath [
aria2
coreutils
gnugrep
which
xdg-utils
]}:\$PATH"
electron_args=(--no-sandbox)
app_args=()
root_path="\$(${coreutils}/bin/readlink -f /proc/self/root)"
if [ "\$root_path" != "/" ]; then
app_args+=(--no-apm)
fi
if [ "\''${IS_ACE_ENV:-}" = "1" ]; then
app_args+=(--no-apm)
fi
if ! command -v apt >/dev/null 2>&1; then
app_args+=(--no-spark)
fi
if [ -r /etc/os-release ] && ${gnugrep}/bin/grep -q "ID=aosc" /etc/os-release; then
app_args+=(--no-spark)
fi
exec ${electron}/bin/electron "\''${electron_args[@]}" "$appDir" "\''${app_args[@]}" "\$@"
EOF
chmod +x "$out/bin/spark-store"
patchShebangs "$appDir/extras"
runHook postInstall
'';
postFixup = ''
appDir="$out/share/spark-store"
wrapProgram "$appDir/extras/shell-caller.sh" \
--prefix PATH : ${
lib.makeBinPath [
aria2
bash
coreutils
which
xdg-utils
]
}
substituteInPlace "$appDir/extras/.shell-caller.sh-wrapped" \
--replace-fail "#!/bin/bash" "#!${bash}/bin/bash"
'';
meta = {
description = "Client for Spark App Store";
homepage = "https://spark-app.store";
license = lib.licenses.gpl3Only;
mainProgram = "spark-store";
platforms = lib.platforms.linux;
};
}
+31 -29
View File
@@ -1,12 +1,12 @@
{
"name": "spark-store",
"version": "5.0.0beta4",
"version": "5.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "spark-store",
"version": "5.0.0beta4",
"version": "5.3.0",
"license": "GPL-3.0",
"dependencies": {
"@tailwindcss/vite": "^4.1.18",
@@ -28,7 +28,7 @@
"@vue/test-utils": "^2.4.3",
"conventional-changelog": "^7.1.1",
"conventional-changelog-angular": "^8.1.0",
"electron": "^40.0.0",
"electron": "^37.2.5",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.5",
@@ -45,7 +45,7 @@
"vite-plugin-electron-renderer": "^0.14.5",
"vitest": "^4.1.4",
"vue": "^3.4.21",
"vue-tsc": "^3.2.4"
"vue-tsc": "^3.3.5"
},
"engines": {
"node": ">=22.12.0"
@@ -3348,18 +3348,18 @@
}
},
"node_modules/@vue/language-core": {
"version": "3.2.5",
"resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.2.5.tgz",
"integrity": "sha512-d3OIxN/+KRedeM5wQ6H6NIpwS3P5gC9nmyaHgBk+rO6dIsjY+tOh4UlPpiZbAh3YtLdCGEX4M16RmsBqPmJV+g==",
"version": "3.3.5",
"resolved": "https://registry.npmmirror.com/@vue/language-core/-/language-core-3.3.5.tgz",
"integrity": "sha512-UkKu5nhX89fg4VhlG/FOeI10G3cj/7radKT/cy9BT4Q9qJmJlSTAc/dP63Xqs29aypN4f39xUV6PsLNk/dcD6g==",
"dev": true,
"dependencies": {
"@volar/language-core": "2.4.28",
"@vue/compiler-dom": "^3.5.0",
"@vue/shared": "^3.5.0",
"alien-signals": "^3.0.0",
"alien-signals": "^3.2.0",
"muggle-string": "^0.4.1",
"path-browserify": "^1.0.1",
"picomatch": "^4.0.2"
"picomatch": "^4.0.4"
}
},
"node_modules/@vue/reactivity": {
@@ -3494,9 +3494,9 @@
}
},
"node_modules/alien-signals": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.1.2.tgz",
"integrity": "sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==",
"version": "3.2.1",
"resolved": "https://registry.npmmirror.com/alien-signals/-/alien-signals-3.2.1.tgz",
"integrity": "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==",
"dev": true
},
"node_modules/ansi-colors": {
@@ -4870,15 +4870,15 @@
}
},
"node_modules/electron": {
"version": "40.8.5",
"resolved": "https://registry.npmjs.org/electron/-/electron-40.8.5.tgz",
"integrity": "sha512-pgTY/VPQKaiU4sTjfU96iyxCXrFm4htVPCMRT4b7q9ijNTRgtLmLvcmzp2G4e7xDrq9p7OLHSmu1rBKFf6Y1/A==",
"version": "37.2.5",
"resolved": "https://registry.npmjs.org/electron/-/electron-37.2.5.tgz",
"integrity": "sha512-719ZqEp43rj6xDJMICm4CIXl8keFFgvVNO9Ix6OtjNjrh9HtYlP/1WiYeRohnXj06aLyGx5NCzrHbG7j3BxO9w==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"@electron/get": "^2.0.0",
"@types/node": "^24.9.0",
"@types/node": "^22.7.7",
"extract-zip": "^2.0.1"
},
"bin": {
@@ -4889,19 +4889,21 @@
}
},
"node_modules/electron/node_modules/@types/node": {
"version": "24.12.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz",
"integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==",
"version": "22.19.17",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz",
"integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.16.0"
"undici-types": "~6.21.0"
}
},
"node_modules/electron/node_modules/undici-types": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"dev": true
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/emoji-regex": {
"version": "8.0.0",
@@ -7634,7 +7636,7 @@
},
"node_modules/muggle-string": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
"resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz",
"integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==",
"dev": true
},
@@ -10086,13 +10088,13 @@
}
},
"node_modules/vue-tsc": {
"version": "3.2.5",
"resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.2.5.tgz",
"integrity": "sha512-/htfTCMluQ+P2FISGAooul8kO4JMheOTCbCy4M6dYnYYjqLe3BExZudAua6MSIKSFYQtFOYAll7XobYwcpokGA==",
"version": "3.3.5",
"resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-3.3.5.tgz",
"integrity": "sha512-Rzh/G2MmNlMSAMTiQEjDrsb4dgB/jbtEM47rVN2NtidF1dfb/q4w4QvpQBtW5+y3y5H27Hjh7deVwk+YB02fNg==",
"dev": true,
"dependencies": {
"@volar/typescript": "2.4.28",
"@vue/language-core": "3.2.5"
"@vue/language-core": "3.3.5"
},
"bin": {
"vue-tsc": "bin/vue-tsc.js"
+10464
View File
File diff suppressed because it is too large Load Diff
+5 -4
View File
@@ -1,6 +1,6 @@
{
"name": "spark-store",
"version": "5.0.0beta4",
"version": "5.3.0",
"main": "dist-electron/main/index.js",
"description": "Client for Spark App Store",
"author": "elysia-best <elysia-best@simplelinux.cn.eu.org>",
@@ -26,10 +26,11 @@
"type": "module",
"scripts": {
"dev": "vite --mode debug | pino-pretty",
"build": "vue-tsc --noEmit && vite build --mode production && electron-builder --config electron-builder.yml",
"build": "vue-tsc --noEmit && vite build --mode production && electron-builder --config electron-builder.yml --linux dir",
"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:deb": "vue-tsc --noEmit && vite build --mode production && electron-builder --config electron-builder.yml --linux deb",
"build: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 dir",
"preview": "vite preview --mode debug",
"lint": "eslint --ext .ts,.vue src electron",
"lint:fix": "eslint --ext .ts,.vue src electron --fix",
@@ -56,7 +57,7 @@
"@vue/test-utils": "^2.4.3",
"conventional-changelog": "^7.1.1",
"conventional-changelog-angular": "^8.1.0",
"electron": "^40.0.0",
"electron": "^37.2.5",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.5",
@@ -73,7 +74,7 @@
"vite-plugin-electron-renderer": "^0.14.5",
"vitest": "^4.1.4",
"vue": "^3.4.21",
"vue-tsc": "^3.2.4"
"vue-tsc": "^3.3.5"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.18",
-3
View File
@@ -1,3 +0,0 @@
Package: *
Pin: origin *.deepinos.org.cn
Pin-Priority: 400
@@ -1 +0,0 @@
deb [by-hash=force] https://d.store.deepinos.org.cn /
@@ -1,11 +0,0 @@
[Unit]
Description=Timer for Spark Update Notifier
[Timer]
# 开机后第一次执行
OnBootSec=1min
# 每天执行一次
OnUnitActiveSec=1d
[Install]
WantedBy=timers.target
-9
View File
@@ -1,9 +0,0 @@
#!/bin/sh
echo "From:sparkstorefeedback@163.com
To:sparkstorefeedback@163.com
Subject: spark-store_3.0.2: $(lsb_release -a | grep "Description" | sed -e "s#\t#@#" | cut -d "@" -f 2)
$(uname -a)" | tee /tmp/spark-store-install/feedback.txt > /dev/null
curl -s --url "smtp://smtp.163.com" --mail-from "${MAIL_FEEDBACK}" --mail-rcpt "${MAIL_FEEDBACK}" --upload-file /tmp/spark-store-install/feedback.txt --user "${MAIL_FEEDBACK}:${M}AIL_AUTH"
@@ -1,15 +1,17 @@
[Unit]
Description=Spark Store update notifier
After=apt-daily.service network.target network-online.target systemd-networkd.service NetworkManager.service connman.service
# 设置为 1 小时的时间窗口
StartLimitIntervalSec=1h
# 最大允许失败次数为 3 次
StartLimitBurst=3
[Service]
Type=simple
RemainAfterExit=yes
ExecStart=/opt/durapps/spark-store/bin/update-upgrade/ss-update-notifier.sh
Restart=on-failure
RestartSec=15 # 可以设置为更长的重试间隔,比如 15 秒或 30 秒
StartLimitIntervalSec=1h # 设置为 1 小时的时间窗口
StartLimitBurst=3 # 最大允许失败次数为 3 次
# 可以设置为更长的重试间隔,比如 15 秒或 30 秒
RestartSec=15
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,10 @@
[Unit]
Description=Spark Store update notifier
[Timer]
OnCalendar=*-*-* 6,12,18:00
RandomizedDelaySec=30m
Persistent=true
[Install]
WantedBy=timers.target
@@ -10,4 +10,4 @@ Keywords=appstore;
Terminal=false
StartupNotify=true
StartupWMClass=spark-store
MimeType=x-scheme-handler/spk
MimeType=x-scheme-handler/spk;x-scheme-handler/apt
@@ -0,0 +1,5 @@
#!/bin/bash
TRANSHELL_CONTENT_RUNNING_IN_NOT_ROOT_USER="Informação: Iniciando em modo sem privilégios root! Se ocorrerem problemas, tente executar o comando com privilégios de root."
TRANSHELL_CONTENT_INFO_SOURCES_LIST_D_IS_EMPTY="Informação: A pasta sources.list.d está vazia. Nenhuma sincronização será tentada."
TRANSHELL_CONTENT_GETTING_SERVER_CONFIG_AND_MIRROR_LIST="Obtendo configuração do servidor e lista de espelhos..."
TRANSHELL_CONTENT_PLEASE_USE_APTSS_INSTEAD_OF_APT="Aviso: Embora a mensagem de erro sugira usar apt (ex.: apt install --fix-broken) para corrigir o problema, ao depurar o erro, utilize aptss no lugar (no exemplo, use aptss install --fix-broken)."
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env bash
#
# 测试打包脚本
#
# 用途:本地测试打包时,自动将 deb 版本号(位于 debian/changelog 顶部)的
# 最后一段数字 +1,并追加 "-test" 标签,随后执行 dpkg-buildpackage。
#
# 示例:
# 当前 debian/changelog 顶部版本为 5.2.1.0
# 运行一次 -> 5.2.1.1-test(产物 spark-store_5.2.1.1-test_amd64.deb
# 再运行一次 -> 5.2.1.2-test
#
# 注意:
# - 此脚本仅用于本地测试,不会修改 package.json(其 version 为 semver 格式,
# 而 deb 测试版本如 5.3.0.8-test 会被 electron-builder 拒绝)。
# - 为保证打包后「关于」页面显示版本与 deb 版本一致,脚本会临时向
# electron-builder.yml 追加 extraMetadata.versionelectron-builder 会将其写入
# app.asar 内的 package.jsonElectron 的 app.getVersion() 即从中读取),
# 打包完成后自动恢复,异常退出时由 trap 兜底恢复。
# - 每次运行都会改写 debian/changelog,属于未跟踪的工作区改动,按需自行还原。
# - 如需恢复正式版本,将 debian/changelog 顶部版本改回即可。
set -euo pipefail
# 定位仓库根目录(脚本位于 scripts/ 下)
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
CHANGELOG="debian/changelog"
if [ ! -f "$CHANGELOG" ]; then
echo "错误:未找到 $CHANGELOG" >&2
exit 1
fi
# 读取 changelog 首行并解析版本号,形如:
# spark-store (5.2.1.0) UNRELEASED; urgency=medium
# spark-store (5.2.1.1-test) UNRELEASED; urgency=medium
top_line="$(head -n1 "$CHANGELOG")"
# 提取括号内的版本号,避免使用 [[ =~ ]] 处理字面括号引发的语法问题
old_ver="$(printf '%s' "$top_line" | sed -E 's/^[^ ]+ \(([^)]+)\).*/\1/')"
if [[ -z "$old_ver" || "$old_ver" == "$top_line" ]]; then
echo "错误:无法从 $CHANGELOG 首行解析版本号: $top_line" >&2
exit 1
fi
# 分离上游版本与 Debian 修订号:
# Debian 版本格式为 "上游版本-修订号",修订号以最后一个 '-' 分隔。
# 上游版本只允许点号(如 5.2.1.0),'-test' 这类标签属于修订号,每次测试覆盖为固定 "test"。
if [[ "$old_ver" == *-* ]]; then
upstream="${old_ver%-*}"
else
upstream="$old_ver"
fi
# 上游版本形如 X.Y.Z.W,将最后一个点号分隔的数字段 +1
if [[ "$upstream" =~ ^(.*)\.([0-9]+)$ ]]; then
prefix="${BASH_REMATCH[1]}"
last="${BASH_REMATCH[2]}"
# 10# 强制按十进制解析,避免以 0 开头的段被当作八进制
new_last=$((10#$last + 1))
new_upstream="${prefix}.${new_last}"
else
echo "错误:上游版本号不符合 X.Y.Z.W 格式: $upstream" >&2
exit 1
fi
new_ver="${new_upstream}-test"
echo "版本号: ${old_ver} -> ${new_ver}"
# 仅替换首行括号内的版本号(转义点号,避免被正则当作任意字符)
old_ver_esc="${old_ver//./\\.}"
sed -i -E "1s/\\(${old_ver_esc}\\)/(${new_ver})/" "$CHANGELOG"
# Electron 二进制下载镜像(本机到 GitHub 不通,使用 npmmirror 镜像)
export ELECTRON_MIRROR="${ELECTRON_MIRROR:-https://registry.npmmirror.com/-/binary/electron/}"
# 同步应用内版本号:临时向 electron-builder.yml 追加 extraMetadata.version。
# electron-builder 会把它合并进 app.asar 内的 package.json
# 「关于」页面经 IPC 调用的 app.getVersion() 即读取该值。
EB_YML="electron-builder.yml"
EB_YML_BAK="${EB_YML}.bak-test-build"
# 清理上次异常中断可能残留的 extraMetadata 块(追加内容始终位于文件末尾)
if grep -q "^extraMetadata:" "$EB_YML"; then
echo "清理 $EB_YML 中残留的 extraMetadata 块"
sed -i '/^extraMetadata:/,$d' "$EB_YML"
fi
cp "$EB_YML" "$EB_YML_BAK"
restore_eb_yml() {
mv -f "$EB_YML_BAK" "$EB_YML" 2>/dev/null || true
}
trap restore_eb_yml EXIT
printf 'extraMetadata:\n version: "%s"\n' "$new_ver" >> "$EB_YML"
# 清理旧的 electron-builder 产物,避免 release/ 下多个版本目录被 make install
# 的 release/*/linux*-unpacked/* 通配一并拷入 deb,造成内容污染
rm -rf release/
echo "开始测试打包(版本 ${new_ver}..."
dpkg-buildpackage -us -uc -b
# 打包成功,立即恢复 electron-builder.yml 并解除 trap
restore_eb_yml
trap - EXIT
echo "完成。产物: ../spark-store_${new_ver}_amd64.deb"
-62
View File
@@ -1,62 +0,0 @@
build
.vscode
.cache
CMakeLists.txt.user
CMakeLists.txt.user.*
obj-x86_64-linux-gnu
# C++ objects and libs
*.slo
*.lo
*.o
*.a
*.la
*.lai
*.so
*.dll
*.dylib
# Qt-es
object_script.*.Release
object_script.*.Debug
*_plugin_import.cpp
/.qmake.cache
/.qmake.stash
*.pro.user
*.pro.user.*
*.qbs.user
*.qbs.user.*
*.moc
moc_*.cpp
moc_*.h
qrc_*.cpp
ui_*.h
*.qmlc
*.jsc
Makefile*
*build-*
# Qt unit tests
target_wrapper.*
# Qt qm files
translations/*.qm
# QtCreator
*.autosave
# QtCreator Qml
*.qmlproject.user
*.qmlproject.user.*
# QtCreator CMake
CMakeLists.txt.user*
build
# Debian dpkg-buildpackage
debian/*.debhelper*
debian/files
debian/*.substvars
debian/spark-update-tool
.vscode/*
src/spark-update-tool
-99
View File
@@ -1,99 +0,0 @@
cmake_minimum_required(VERSION 3.16)
project(spark-update-tool VERSION 0.1 LANGUAGES CXX)
set(CMAKE_AUTOUIC ON)
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTORCC ON)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
find_package(QT NAMES Qt6 Qt5 REQUIRED COMPONENTS Widgets Network Concurrent)
find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Widgets Network Concurrent)
set(PROJECT_SOURCES
src/main.cpp
src/mainwindow.cpp
src/mainwindow.h
src/mainwindow.ui
src/aptssupdater.h
src/aptssupdater.cpp
src/icons.qrc
src/appdelegate.h
src/appdelegate.cpp
src/applistmodel.h
src/applistmodel.cpp
src/downloadmanager.h
src/downloadmanager.cpp
src/ignoreconfig.h
src/ignoreconfig.cpp
)
if(${QT_VERSION_MAJOR} GREATER_EQUAL 6)
qt_add_executable(spark-update-tool
MANUAL_FINALIZATION
${PROJECT_SOURCES}
)
# Define target properties for Android with Qt 6 as:
# set_property(TARGET spark-update-tool APPEND PROPERTY QT_ANDROID_PACKAGE_SOURCE_DIR
# ${CMAKE_CURRENT_SOURCE_DIR}/android)
# For more information, see https://doc.qt.io/qt-6/qt-add-executable.html#target-creation
else()
if(ANDROID)
add_library(spark-update-tool SHARED
${PROJECT_SOURCES}
)
# Define properties for Android with Qt 5 after find_package() calls as:
# set(ANDROID_PACKAGE_SOURCE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/android")
else()
add_executable(spark-update-tool
${PROJECT_SOURCES}
)
endif()
endif()
target_link_libraries(spark-update-tool PRIVATE Qt${QT_VERSION_MAJOR}::Widgets Qt${QT_VERSION_MAJOR}::Network Qt${QT_VERSION_MAJOR}::Concurrent)
# Qt for iOS sets MACOSX_BUNDLE_GUI_IDENTIFIER automatically since Qt 6.1.
# If you are developing for iOS or macOS you should consider setting an
# explicit, fixed bundle identifier manually though.
if(${QT_VERSION} VERSION_LESS 6.1.0)
set(BUNDLE_ID_OPTION MACOSX_BUNDLE_GUI_IDENTIFIER com.example.spark-update-tool)
endif()
set_target_properties(spark-update-tool PROPERTIES
${BUNDLE_ID_OPTION}
MACOSX_BUNDLE_BUNDLE_VERSION ${PROJECT_VERSION}
MACOSX_BUNDLE_SHORT_VERSION_STRING ${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}
MACOSX_BUNDLE TRUE
WIN32_EXECUTABLE TRUE
)
#
# Linux/usr/bin
if(UNIX AND NOT APPLE)
# /usr/bin
install(TARGETS spark-update-tool
RUNTIME DESTINATION /usr/bin
)
#
# install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/spark-update-tool.desktop
# DESTINATION /usr/share/applications
# )
# install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/icons/spark-update-tool.png
# DESTINATION /usr/share/icons/hicolor/256x256/apps
# )
else()
# 使GNU
include(GNUInstallDirs)
install(TARGETS spark-update-tool
BUNDLE DESTINATION .
LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
)
endif()
if(QT_VERSION_MAJOR EQUAL 6)
qt_finalize_executable(spark-update-tool)
endif()
-674
View File
@@ -1,674 +0,0 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
-15
View File
@@ -1,15 +0,0 @@
### Spark Update Tool
#### Introduction
Welcome to Spark Software Updater. Use this tool to update applications on your Linux system.
This version is specifically designed for Linux distributions with Qt6 support.
Please run under root privileges (recommended: use `sudo`).
#### Currently Supported Linux Distributions
- [x] GXDE OS
- [x] Ubuntu
- [x] deepin
- [ ] Kylin
#### Contact & Feedback
momen@momen.world
-25
View File
@@ -1,25 +0,0 @@
### 星火软件更新器
#### 简介
欢迎使用星火软件更新器,您可以使用此更新器更新位于您 Linux 计算机的程序。
此版本专为有qt6的Linux发行版所使用。
请在root环境下运行。
#### 当前支持的 Linux 发行版
- [x] GXDE OS
- [x] Ubuntu
- [x] deepin
- [ ] Kylin
#### 功能清单
| 功能模块 | 描述 |
|------------------|--------------------------------------|
| 应用名识别 | 基于 `ss-do-upgrade.sh` 部分代码实现 |
| 应用包大小识别 | 通过 dpkg 获取包大小信息 |
| 获取应用图标 | 利用 QDesktopServices 实现 |
| 支持 ACE 兼容环境| |
| 多线程下载 | 基于 aptss 方案 |
如您已安装星火应用商店,则会附带本程序。
#### 联系与反馈
momen@momen.world
-27
View File
@@ -1,27 +0,0 @@
spark-update-tool (1.0.4) unstable; urgency=low
* 修复点击更新全部按钮后,会更新被忽略应用的问题。
spark-update-tool (1.0.3) unstable; urgency=low
* 修复默认图标加载失败的问题
* 修复更新器在安装阶段强制关闭窗口后再次更新无法安装软件包的问题。
-- momen <vmomenv@gmail.com> Fri, 17 Oct 2025 00:00:00 +0000
spark-update-tool (1.0.2) unstable; urgency=low
* 添加复选框,选择多个包更新
* 修复缩放问题
* 添加忽略应用功能
-- momen <vmomenv@gmail.com> Mon, 29 Sep 2025 00:00:00 +0000
spark-update-tool (1.0.1) unstable; urgency=low
* 修复窗口调整大小时的错误
-- momen <vmomenv@gmail.com> Wed, 18 Jun 2025 00:00:00 +0000
spark-update-tool (1.0.0) unstable; urgency=low
* Initial release.
-- momen <vmomenv@gmail.com> Wed, 18 Jun 2025 00:00:00 +0000
-1
View File
@@ -1 +0,0 @@
13
-13
View File
@@ -1,13 +0,0 @@
Source: spark-update-tool
Section: utils
Priority: optional
Maintainer: momen <vmomenv@gmail.com>
Build-Depends: debhelper (>= 9)
Standards-Version: 3.9.6
Homepage: https://gitee.com/spark-store-project/Spark-Update-Tool
Package: spark-update-tool
Architecture: any
Depends: ${shlibs:Depends}, ${misc:Depends}
Description: Spark Update Tool
星火应用商店更新组件。This package provides the Spark Update Tool. It includes features for checking for updates, downloading, and applying them seamlessly.
-7
View File
@@ -1,7 +0,0 @@
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: spark-update-tool
Source: https://gitee.com/spark-store-project/Spark-Update-Tool
Files: *
Copyright: 2025, momen <vmomenv@gmail.com>
License: GPL-3.0+
-3
View File
@@ -1,3 +0,0 @@
build/spark-update-tool /usr/bin/
debian/spark-update-tool.desktop /usr/share/applications
resources/128*128/spark-update-tool.png /usr/share/icons/hicolor/128x128/apps
-11
View File
@@ -1,11 +0,0 @@
#!/bin/sh
set -e
case "$1" in
purge)
rm -rf /usr/share/spark-update-tool
;;
esac
exit 0
-27
View File
@@ -1,27 +0,0 @@
#!/usr/bin/make -f
# 声明兼容性级别
export DH_VERBOSE=1
%:
dh $@ --buildsystem=cmake
# 确保使用CMake进行配置
override_dh_auto_configure:
dh_auto_configure -- -DCMAKE_INSTALL_PREFIX=/usr
# 确保使用CMake进行构建
override_dh_auto_build:
dh_auto_build
# 确保使用CMake进行安装
override_dh_auto_install:
dh_auto_install
# 确保使用CMake进行清理
override_dh_auto_clean:
dh_auto_clean
# 确保使用CMake进行依赖解析
override_dh_shlibdeps:
dh_shlibdeps --dpkg-shlibdeps-params=--ignore-missing-info
-1
View File
@@ -1 +0,0 @@
3.0 (native)
@@ -1,9 +0,0 @@
[Desktop Entry]
Version=1.0
Name=Spark Update Tool
Comment=A Qt-based application for managing and updating software
Exec=spark-update-tool
Icon=spark-update-tool
Terminal=false
Type=Application
Categories=Utility;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 86 KiB

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="12" height="16" viewBox="0 0 12 16"><defs><clipPath id="master_svg0_1_074"><rect x="0.75" y="2" width="10.5" height="12" rx="0"/></clipPath></defs><g><rect x="0" y="0" width="12" height="16" rx="0" fill="#000000" fill-opacity="0" style="mix-blend-mode:passthrough"/><g clip-path="url(#master_svg0_1_074)"><g transform="matrix(1,0,0,-1,0,24.015625)"><g><path d="M4.71094,12.2187505Q4.94531,12.0078125,5.25,12.0078125Q5.55469,12.0078125,5.78906,12.2187505L10.2891,16.7187525Q10.5,16.9531225,10.5,17.2578125Q10.5,17.5625025,10.2891,17.7968725Q10.0547,18.0078125,9.75,18.0078125Q9.44531,18.0078125,9.21094,17.7968725L5.25,13.8125025L1.28906,17.7968725Q1.05469,18.0078125,0.75,18.0078125Q0.445312,18.0078125,0.210938,17.7968725Q0,17.5625025,0,17.2578125Q0,16.9531225,0.210938,16.7187525L4.71094,12.2187505Z" fill="#4B5563" fill-opacity="1" style="mix-blend-mode:passthrough"/></g></g></g></g></svg>

Before

Width:  |  Height:  |  Size: 1009 B

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.
-54
View File
@@ -1,54 +0,0 @@
QT += core gui widgets network concurrent
TARGET = spark-update-tool
TEMPLATE = app
# Set C++ standard to C++17
CONFIG += c++17
# Enable auto features (uic, moc, rcc)
CONFIG += qt warn_on release
# Version info
VERSION = 0.1.0
DEFINES += APP_VERSION=\\\"$$VERSION\\\"
# Source files
SOURCES += \
src/main.cpp \
src/mainwindow.cpp \
src/aptssupdater.cpp \
src/appdelegate.cpp \
src/applistmodel.cpp \
src/downloadmanager.cpp \
src/ignoreconfig.cpp
HEADERS += \
src/mainwindow.h \
src/aptssupdater.h \
src/appdelegate.h \
src/applistmodel.h \
src/downloadmanager.h \
src/ignoreconfig.h
FORMS += \
src/mainwindow.ui
RESOURCES += \
src/icons.qrc
# Linux-specific settings
unix:!macx {
# 安装到 /usr/bin 目录
target.path = /usr/bin
INSTALLS += target
# 如果需要安装其他文件(如桌面文件图标等),可以添加
# desktop.path = /usr/share/applications
# desktop.files = spark-update-tool.desktop
# INSTALLS += desktop
# Additional Linux specific configurations if needed
QMAKE_CXXFLAGS += -Wall -Wextra
}
# Remove Windows and macOS specific sections since we're focusing on Linux
File diff suppressed because it is too large Load Diff
-75
View File
@@ -1,75 +0,0 @@
#pragma once
#include <QStyledItemDelegate>
#include <QHash>
#include <QQueue>
#include <QProcess>
#include <QElapsedTimer>
#include <QTimer>
#include <QSet>
#include "downloadmanager.h"
struct DownloadInfo {
int progress = 0;
bool isDownloading = false;
bool isInstalled = false;
bool isInstalling = false;
};
class AppDelegate : public QStyledItemDelegate {
Q_OBJECT
public:
explicit AppDelegate(QObject *parent = nullptr);
~AppDelegate();
void setModel(QAbstractItemModel *model);
void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override;
QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override;
bool editorEvent(QEvent *event, QAbstractItemModel *model,
const QStyleOptionViewItem &option, const QModelIndex &index) override;
void startDownloadForAll();
void startDownloadForSelected();
// 复选框相关方法
void setSelectedPackages(const QSet<QString> &selected);
QSet<QString> getSelectedPackages() const;
void clearSelection();
// 获取下载状态信息
const QHash<QString, DownloadInfo>& getDownloads() const;
signals:
void updateDisplay(const QString &packageName);
void updateFinished(bool success); //传递是否完成更新
void ignoreApp(const QString &packageName, const QString &version); // 新增:忽略应用信号
void unignoreApp(const QString &packageName); // 新增:取消忽略应用信号
private slots:
void updateSpinner(); // 新增槽函数
private:
DownloadManager *m_downloadManager;
QHash<QString, DownloadInfo> m_downloads;
QAbstractItemModel *m_model = nullptr;
// 复选框相关成员变量
QSet<QString> m_selectedPackages;
// 迁移包集合(用户确认要迁移的包)
QSet<QString> m_migrationPackages;
QQueue<QString> m_installQueue;
bool m_isInstalling = false;
QProcess *m_installProcess = nullptr;
QString m_installingPackage;
QElapsedTimer m_spinnerTimer;
QTimer m_spinnerUpdateTimer; // 新增定时器
int m_spinnerAngle = 0; // 新增角度变量
void enqueueInstall(const QString &packageName);
void startNextInstall();
};

Some files were not shown because too many files have changed in this diff Show More