Compare commits

..
97 Commits
Author SHA1 Message Date
shenmo7192 2db91252ad chore(release): bump version to 5.3.1 2026-08-19 22:06:10 +08:00
shenmo7192 86895269c3 fix(ui): 放大首页标题与启动提示,放开外部链接域名限制 2026-08-19 22:06:10 +08:00
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
79 changed files with 8795 additions and 3589 deletions
+1 -1
View File
@@ -1,3 +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_APM_STORE_STATS_BASE_URL=https://feedback.spark-app.store
VITE_SPARK_BACKEND_BASE_URL=https://account.spark-app.store VITE_SPARK_BACKEND_BASE_URL=https://account.spark-app.store
+13
View File
@@ -55,3 +55,16 @@ test-results.json
# VSCode CMake Extension # VSCode CMake Extension
build/* 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": [ "runtimeArgs": [
"--remote-debugging-port=9229", "--remote-debugging-port=9229",
"--no-sandbox",
"." "."
], ],
"envFile": "${workspaceFolder}/.vscode/.debug.env", "envFile": "${workspaceFolder}/.vscode/.debug.env",
+17
View File
@@ -695,6 +695,23 @@ npm run build:rpm # 仅构建 RPM 包
- `dist/` - 编译的渲染器资源 - `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:** **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) ## [1.1.1](https://github.com/elysia-best/apm-app-store/compare/v1.1.0...v1.1.1) (2026-02-17)
+1 -1
View File
@@ -47,4 +47,4 @@ install:
cp -rv pkg/usr/share/dsg/ $(DESTDIR)/usr/share/ cp -rv pkg/usr/share/dsg/ $(DESTDIR)/usr/share/
cp -rv pkg/usr/share/bash-completion/ $(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/ 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 # ln -s ../../../spark-store/extras/spark-store $(DESTDIR)/opt/durapps/spark-store/bin/spark-store
+3 -3
View File
@@ -1,5 +1,5 @@
spark-store (5.2.1-0) UNRELEASED; urgency=medium spark-store (5.3.1) UNRELEASED; urgency=medium
* Initial release. (Closes: #nnnn) <nnnn is the bug number of your ITP> * Initial release.
-- shenmo <shenmo@spark-app.store> Tue, 16 Jun 2026 21:45:35 +0800 -- shenmo <shenmo@spark-app.store> Fri, 14 Aug 2026 15:14:01 +0800
+3
View File
@@ -58,6 +58,9 @@ case "$1" in
systemctl enable spark-update-notifier systemctl enable spark-update-notifier
systemctl start 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 # 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 cp -fv /opt/spark-store/extras/store.spark-app.spark-store.policy /usr/share/polkit-1/actions/store.spark-app.spark-store.policy
@@ -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` 设计原型与代码核查结论编写,作为后续开发与迭代的执行依据。*
+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 明确显示「安装失败」而非「下载完成」
+415 -113
View File
@@ -12,6 +12,32 @@ import { findExecutable, SUPER_USER_COMMAND_CANDIDATES } from "./superuser";
const logger = pino({ name: "install-manager" }); const logger = pino({ name: "install-manager" });
// 包名白名单:仅允许合法包名字符,杜绝命令注入(spawn 用 shell:false 仍须校验)。
const PKGNAME_PATTERN = /^[a-zA-Z0-9._+-]+$/;
// 文件名(.deb)白名单:按 dpkg 规则允许包名/版本号合法字符。
// 含 epoch 冒号(steam-launcher_1:1.0.0.87_amd64.deb)与排序标记 ~1.0~beta),
// 以及字母数字、. _ + -。路径遍历由下方 basename/".." 检查兜底。
// 注意字符组内 : 与 ~ 须用 \- 等转义字符隔离,避免形成正则区间(如 [:~] 会误匹配大量符号)。
const FILENAME_PATTERN = /^[A-Za-z0-9._+:\-~]+$/;
// 解析并校验应用类 IPC 的 payload(可能是旧版字符串或对象)。
// 返回规范化后的 { pkgname, origin }pkgname 非法时返回 null。
const parseAppPayload = (
payload: unknown,
): { pkgname: string; origin: "spark" | "apm" } | null => {
if (typeof payload === "string") {
if (!PKGNAME_PATTERN.test(payload)) return null;
return { pkgname: payload, origin: "spark" };
}
if (typeof payload !== "object" || payload === null) return null;
const p = payload as Record<string, unknown>;
const pkgname = typeof p.pkgname === "string" ? p.pkgname : "";
if (!PKGNAME_PATTERN.test(pkgname)) return null;
const origin: "spark" | "apm" = p.origin === "apm" ? "apm" : "spark";
return { pkgname, origin };
};
const getStoreFilterFromArgv = (): "spark" | "apm" | "both" => { const getStoreFilterFromArgv = (): "spark" | "apm" | "both" => {
const argv = process.argv; const argv = process.argv;
const noApm = argv.includes("--no-apm"); const noApm = argv.includes("--no-apm");
@@ -30,6 +56,18 @@ const isOriginEnabled = (
return storeFilter === "both" || storeFilter === origin; return storeFilter === "both" || storeFilter === origin;
}; };
export interface QueueInstallPayload {
id: number;
pkgname: string;
metalinkUrl?: string;
filename?: string;
origin?: "spark" | "apm";
upgradeOnly?: boolean;
retry?: boolean;
// 强制安装被系统锁定(apt-mark hold)的包:spark 源安装时追加 --allow-change-held-packages
forceHeld?: boolean;
}
type InstallTask = { type InstallTask = {
id: number; id: number;
pkgname: string; pkgname: string;
@@ -42,7 +80,9 @@ type InstallTask = {
metalinkUrl?: string; metalinkUrl?: string;
filename?: string; filename?: string;
origin: "spark" | "apm"; origin: "spark" | "apm";
upgradeOnly?: boolean;
cancelled?: boolean; cancelled?: boolean;
forceHeld?: boolean;
phase: "queued-download" | "downloading" | "queued-install" | "installing"; phase: "queued-download" | "downloading" | "queued-install" | "installing";
}; };
@@ -262,35 +302,170 @@ const parseUpgradableList = (output: string) => {
return apps; return apps;
}; };
// Listen for download requests from renderer process /**
ipcMain.on("queue-install", async (event, download_json) => { * 将任务加入安装/下载队列。被 IPC 监听与更新中心等内部模块共享使用。
const download = * 入队前会执行包名、文件名、metalinkUrl 的校验,并检查重复任务。
typeof download_json === "string" * @returns 是否成功入队
? JSON.parse(download_json) */
: download_json; export const addInstallTask = async (
const { id, pkgname, metalinkUrl, filename, origin } = download || {}; download: QueueInstallPayload,
sender: WebContents | null,
): Promise<boolean> => {
const webContents = sender;
const { id, pkgname, filename, origin, upgradeOnly, forceHeld } = download;
// metalinkUrl 可能在下方校验时被规范化(折叠 ./ 段),故用 let
let { metalinkUrl } = download;
if (!id || !pkgname) { if (!id || !pkgname) {
logger.warn("passed arguments missing id or pkgname"); logger.warn("addInstallTask: passed arguments missing id or pkgname");
return; webContents?.send("install-complete", {
id: id ?? 0,
success: false,
time: Date.now(),
exitCode: -1,
message: JSON.stringify({
message: "任务参数缺失 id 或 pkgname",
stdout: "",
stderr: "",
}),
});
return false;
}
// 包名白名单校验:杜绝命令注入(spawn shell:false 仍须校验,且包名不含路径字符)。
if (!PKGNAME_PATTERN.test(pkgname)) {
logger.warn(`addInstallTask invalid pkgname: ${pkgname}`);
webContents?.send("install-complete", {
id,
success: false,
time: Date.now(),
exitCode: -1,
message: JSON.stringify({
message: `包名包含非法字符: ${pkgname}`,
stdout: "",
stderr: "",
}),
});
return false;
}
// 文件名(.deb)白名单校验:允许 Debian 包合法字符(含 epoch 冒号),并拒绝路径遍历
//"/" 与 ".." 段),防止写入下载目录之外的位置。
if (filename) {
if (!FILENAME_PATTERN.test(filename) || filename.includes("..") || path.basename(filename) !== filename) {
logger.warn(`addInstallTask invalid filename: ${filename}`);
webContents?.send("install-complete", {
id,
success: false,
time: Date.now(),
exitCode: -1,
message: JSON.stringify({
message: `文件名包含非法字符: ${filename}`,
stdout: "",
stderr: "",
}),
});
return false;
}
}
// metalinkUrl 来自渲染端(由目录 filename / 主进程解析的 downloadUrl 拼成)。
// 纵深防御:仅允许 https 协议、官方域名(含任意子域 *.spark-app.store)的绝对 URL
// 或以 "/" 开头的相对路径(后续由 axios baseURL 拼接官方域名)。
// 先解析并规范化 path 中的 "./" 段(浏览器/Node 实际请求时会自动折叠,此处显式处理,
// 避免把合法的 "./" 误判为非法),再拒绝 ".." 路径遍历越界与外部域名(防 SSRF)。
if (metalinkUrl) {
let parsed: URL;
try {
// 相对路径以官方域为基进行解析
parsed = new URL(metalinkUrl, "https://erotica.spark-app.store");
} catch {
logger.warn(`addInstallTask invalid metalinkUrl (parse failed): ${metalinkUrl}`);
webContents?.send("install-complete", {
id,
success: false,
time: Date.now(),
exitCode: -1,
message: JSON.stringify({
message: `Metalink URL 不合法: ${metalinkUrl}`,
stdout: "",
stderr: "",
}),
});
return false;
}
if (parsed.protocol !== "https:") {
logger.warn(`addInstallTask invalid metalinkUrl (not https): ${metalinkUrl}`);
webContents?.send("install-complete", {
id,
success: false,
time: Date.now(),
exitCode: -1,
message: JSON.stringify({
message: `Metalink URL 协议不合法: ${metalinkUrl}`,
stdout: "",
stderr: "",
}),
});
return false;
}
// 仅允许官方域名(主域 spark-app.store 及其任意子域,如 erotica/d/store 等)
const host = parsed.hostname;
const isOfficial =
host === "spark-app.store" || host.endsWith(".spark-app.store");
if (!isOfficial) {
logger.warn(`addInstallTask invalid metalinkUrl (host not official): ${host}`);
webContents?.send("install-complete", {
id,
success: false,
time: Date.now(),
exitCode: -1,
message: JSON.stringify({
message: `Metalink URL 域名不合法: ${metalinkUrl}`,
stdout: "",
stderr: "",
}),
});
return false;
}
// 规范化 path:折叠 "." / ".." 段,拒绝越界路径遍历
const normalizedPath = path.posix.normalize(parsed.pathname);
if (normalizedPath.split("/").includes("..")) {
logger.warn(`addInstallTask invalid metalinkUrl (path traversal): ${metalinkUrl}`);
webContents?.send("install-complete", {
id,
success: false,
time: Date.now(),
exitCode: -1,
message: JSON.stringify({
message: `Metalink URL 含路径遍历: ${metalinkUrl}`,
stdout: "",
stderr: "",
}),
});
return false;
}
parsed.pathname = normalizedPath;
// 用规范化后的 URL 继续(去除 "./" 段,与实际请求行为一致)
metalinkUrl = parsed.href;
} }
logger.info(`收到下载任务: ${id}, 软件包名称: ${pkgname}, 来源: ${origin}`); logger.info(`收到下载任务: ${id}, 软件包名称: ${pkgname}, 来源: ${origin}`);
const webContents = event.sender;
// 避免重复添加同一任务(检查 pkgname + origin),但允许重试下载 // 避免重复添加同一任务(检查 pkgname + origin),但允许重试下载
if (!download.retry) { if (!download.retry) {
const existingTask = Array.from(tasks.values()).find( const existingTask = Array.from(tasks.values()).find(
(t) => t.pkgname === pkgname && t.origin === origin, (t) => t.pkgname === pkgname && t.origin === origin,
); );
if (existingTask) { if (existingTask) {
webContents.send("install-log", { webContents?.send("install-log", {
id, id,
time: Date.now(), time: Date.now(),
message: `任务 ${pkgname} (${origin}) 已在列表中,忽略重复添加`, message: `任务 ${pkgname} (${origin}) 已在列表中,忽略重复添加`,
}); });
webContents.send("install-complete", { webContents?.send("install-complete", {
id, id,
success: false, success: false,
time: Date.now(), time: Date.now(),
@@ -301,20 +476,25 @@ ipcMain.on("queue-install", async (event, download_json) => {
stderr: "", stderr: "",
}), }),
}); });
return; return false;
} }
} }
const superUserCmd = await checkSuperUserCommand(); const superUserCmd = await checkSuperUserCommand();
let execCommand = ""; let execCommand = "";
const execParams = []; const execParams: string[] = [];
const downloadDir = `/tmp/spark-store/download/${pkgname}`; const downloadDir = path.join(
os.tmpdir(),
`spark-store-${process.pid}`,
"download",
pkgname,
);
// APM 应用:若本机没有 apm 命令,通知前端弹窗引导安装 APM // APM 应用:若本机没有 apm 命令,通知前端弹窗引导安装 APM
if (origin === "apm") { if (origin === "apm") {
const hasApm = await checkApmAvailable(); const hasApm = await checkApmAvailable();
if (!hasApm) { if (!hasApm) {
webContents.send("trigger-apm-install-dialog"); webContents?.send("trigger-apm-install-dialog");
webContents.send("install-complete", { webContents?.send("install-complete", {
id, id,
success: false, success: false,
time: Date.now(), time: Date.now(),
@@ -325,7 +505,7 @@ ipcMain.on("queue-install", async (event, download_json) => {
stderr: "", stderr: "",
}), }),
}); });
return; return false;
} }
} }
@@ -334,13 +514,29 @@ ipcMain.on("queue-install", async (event, download_json) => {
if (superUserCmd) execParams.push(SHELL_CALLER_PATH); if (superUserCmd) execParams.push(SHELL_CALLER_PATH);
if (metalinkUrl && filename) { if (metalinkUrl && filename) {
execParams.push( const localDeb = `${downloadDir}/${filename}`;
"ssinstall", if (forceHeld) {
`${downloadDir}/${filename}`, // 强制安装被系统锁定(hold)的包:合并为单次免密 pkexec(复用商店已有的
"--delete-after-install", // shell-caller 提权配置),由 shell-caller 的 force-ssinstall 分支内部完成
"--no-create-desktop-entry", // apt-mark unhold → ssinstall 本地 .deb → apt-mark hold,避免单独 pkexec apt-mark
"--native", // 触发额外权限框(apt-mark 不在 policykit 免密 exec.path 内)。
); execParams.push(
"force-ssinstall",
pkgname,
localDeb,
"--delete-after-install",
"--no-create-desktop-entry",
);
} else {
// 本地 .deb 文件模式:已下载的 .deb 通过 ssinstall 安装。
execParams.push(
"ssinstall",
localDeb,
"--delete-after-install",
"--no-create-desktop-entry",
"--native",
);
}
} else { } else {
execParams.push( execParams.push(
"ssinstall", "ssinstall",
@@ -358,7 +554,14 @@ ipcMain.on("queue-install", async (event, download_json) => {
execParams.push("apm"); execParams.push("apm");
if (metalinkUrl && filename) { if (metalinkUrl && filename) {
execParams.push("ssinstall", `${downloadDir}/${filename}`); // 防御性深度校验:即便 PKGNAME_PATTERN 已挡掉路径遍历字符,仍用 path.basename
// 确保 filename 为纯文件名、不含目录分量(belt-and-suspenders
const safeFilename = path.basename(filename);
if (safeFilename !== filename) {
logger.warn(`ssinstall filename contains path traversal: ${filename}`);
return false;
}
execParams.push("ssinstall", path.join(downloadDir, safeFilename));
} else { } else {
execParams.push("install", "-y", pkgname); execParams.push("install", "-y", pkgname);
} }
@@ -376,15 +579,39 @@ ipcMain.on("queue-install", async (event, download_json) => {
metalinkUrl, metalinkUrl,
filename, filename,
origin: origin || "apm", origin: origin || "apm",
upgradeOnly: Boolean(upgradeOnly),
forceHeld: Boolean(forceHeld),
phase: metalinkUrl ? "queued-download" : "queued-install", phase: metalinkUrl ? "queued-download" : "queued-install",
}; };
tasks.set(id, task); tasks.set(id, task);
processNextDownload(); processNextDownload();
processNextInstall(); processNextInstall();
return true;
};
// Listen for download requests from renderer process
ipcMain.on("queue-install", async (event, download_json) => {
let download: unknown;
try {
download =
typeof download_json === "string"
? JSON.parse(download_json)
: download_json;
} catch (err) {
logger.error({ err }, "queue-install: invalid JSON payload, ignoring task");
return;
}
await addInstallTask(download as QueueInstallPayload, event.sender);
}); });
// Cancel Handler // Cancel Handler
ipcMain.on("cancel-install", (event, id) => { ipcMain.on("cancel-install", (event, id) => {
// 防御性输入校验:id 应为整数,避免 Map 以非预期键查找导致逻辑异常
if (typeof id !== "number" || !Number.isInteger(id)) {
logger.warn(`cancel-install: invalid id type: ${typeof id}`);
return;
}
const task = tasks.get(id); const task = tasks.get(id);
if (!task) return; if (!task) return;
@@ -417,9 +644,22 @@ ipcMain.on("cancel-install", (event, id) => {
const isRunning = task.phase === "downloading" || task.phase === "installing"; const isRunning = task.phase === "downloading" || task.phase === "installing";
if (isRunning) { if (isRunning) {
// 运行中的任务:终止进程,由对应的阶段处理器在 finally 中清理计数器与队列 // 运行中的任务:先发 SIGTERM 优雅终止;若 5s 内未退出,降级 SIGKILL 强制杀除,
task.download_process?.kill(); // 避免子进程忽略 SIGTERM 变成僵尸进程(改进项:进程取消需 SIGKILL 降级处理)。
task.install_process?.kill(); const forceKill = (proc: ChildProcess | null, label: string) => {
if (!proc || proc.killed) return;
proc.kill("SIGTERM");
const pid = proc.pid;
setTimeout(() => {
if (proc && !proc.killed) {
logger.warn(`任务 ${id}${label} 进程(${pid}) 未响应 SIGTERM,发送 SIGKILL`);
proc.kill("SIGKILL");
}
}, 5000);
};
forceKill(task.download_process, "下载");
forceKill(task.install_process, "安装");
// 由对应的阶段处理器在 finally 中清理计数器与队列
} else { } else {
// 排队中的任务(未开始执行):直接清理并调度 // 排队中的任务(未开始执行):直接清理并调度
tasks.delete(id); tasks.delete(id);
@@ -496,21 +736,31 @@ async function runDownloadPhase(task: InstallTask) {
sendLog(`正在获取 Metalink 文件: ${task.metalinkUrl}`); sendLog(`正在获取 Metalink 文件: ${task.metalinkUrl}`);
const response = await axios.get(task.metalinkUrl, { let response: Awaited<ReturnType<typeof axios.get>>;
baseURL: "https://erotica.spark-app.store", try {
responseType: "stream", response = await axios.get(task.metalinkUrl, {
}); baseURL: "https://erotica.spark-app.store",
responseType: "stream",
});
} catch (err) {
// Metalink 请求失败(网络/404/超时等):明确回传渲染端,避免 UI 停在"正在获取"后突兀退出
const reason = err instanceof Error ? err.message : String(err);
logger.error(`Task ${id} Metalink 下载请求失败: ${reason}`);
sendLog(`获取 Metalink 失败: ${reason}`);
throw new Error(`获取 Metalink 失败: ${reason}`);
}
const writer = fs.createWriteStream(metalinkPath); const writer = fs.createWriteStream(metalinkPath);
response.data.pipe(writer); response.data.pipe(writer);
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
writer.on("finish", resolve); writer.on("finish", () => {
sendLog("Metalink 文件下载完成");
resolve();
});
writer.on("error", reject); writer.on("error", reject);
}); });
sendLog("Metalink 文件下载完成");
// 清理下载目录中的旧文件(保留 .metalink 文件),防止 aria2c 因同名文件卡住 // 清理下载目录中的旧文件(保留 .metalink 文件),防止 aria2c 因同名文件卡住
const existingFiles = fs.readdirSync(downloadDir); const existingFiles = fs.readdirSync(downloadDir);
for (const file of existingFiles) { for (const file of existingFiles) {
@@ -624,6 +874,7 @@ async function runDownloadPhase(task: InstallTask) {
}); });
child.on("error", (err) => { child.on("error", (err) => {
clearInterval(timeoutChecker); clearInterval(timeoutChecker);
sendLog(`aria2c 启动失败: ${err.message}`);
reject(err); reject(err);
}); });
}); });
@@ -674,6 +925,7 @@ async function runDownloadPhase(task: InstallTask) {
/** /**
* 安装阶段:执行安装命令,安装一次只允许一个。 * 安装阶段:执行安装命令,安装一次只允许一个。
*/ */
async function runInstallPhase(task: InstallTask) { async function runInstallPhase(task: InstallTask) {
const { webContents, id } = task; const { webContents, id } = task;
@@ -759,9 +1011,32 @@ async function runInstallPhase(task: InstallTask) {
}); });
// Completion // Completion
const success = result.code === 0; // ssinstallspark 源)在 dry-run 失败放弃安装时仍可能以退出码 0 结束,
// 需从输出识别明确的失败标志,避免误判为安装成功(否则前端会显示「完成」)。
let success = result.code === 0;
let failureDetail = "";
if (success && task.origin === "spark") {
const failMarkers = [
"放弃安装",
"dry-run测试仍然失败",
"包管理器以错误代码退出",
"Package manager quit with exit code",
];
if (
failMarkers.some(
(marker) =>
result.stdout.includes(marker) || result.stderr.includes(marker),
)
) {
success = false;
failureDetail =
"ssinstall 放弃安装(可能因软件包被系统锁定 hold,需在更新中心开启「强制安装」)";
}
}
const msgObj = { const msgObj = {
message: success ? "安装完成" : `安装失败,退出码 ${result.code}`, message: success
? "安装完成"
: `安装失败:${failureDetail || `退出码 ${result.code}`}`,
stdout: result.stdout, stdout: result.stdout,
stderr: result.stderr, stderr: result.stderr,
}; };
@@ -769,9 +1044,9 @@ async function runInstallPhase(task: InstallTask) {
if (success) { if (success) {
logger.info(msgObj); logger.info(msgObj);
// 安装成功后,如果是APM安装,就调用createApmDesktopShortcut // 安装成功后,如果是APM安装任务,就调用createApmDesktopShortcut
// 这个函数负责处理桌面快捷方式,它自己会读取设置并决定要不要创建 // 升级任务不创建桌面快捷方式;这个函数自己会读取设置并决定要不要创建
if (task.origin === "apm" && task.pkgname) { if (task.origin === "apm" && task.pkgname && !task.upgradeOnly) {
try { try {
await createApmDesktopShortcut(task.pkgname, sendLog); await createApmDesktopShortcut(task.pkgname, sendLog);
} catch (err) { } catch (err) {
@@ -812,15 +1087,13 @@ async function runInstallPhase(task: InstallTask) {
} }
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any ipcMain.handle("check-installed", async (_event, payload: unknown) => {
ipcMain.handle("check-installed", async (_event, payload: any) => { const parsed = parseAppPayload(payload);
const pkgname = typeof payload === "string" ? payload : payload.pkgname; if (!parsed) {
const origin = typeof payload === "string" ? "spark" : payload.origin; logger.warn("check-installed invalid payload");
if (!pkgname) {
logger.warn("check-installed missing pkgname");
return false; return false;
} }
const { pkgname, origin } = parsed;
logger.info(`检查应用是否已安装: ${pkgname} (来源: ${origin})`); logger.info(`检查应用是否已安装: ${pkgname} (来源: ${origin})`);
@@ -887,13 +1160,12 @@ ipcMain.handle("check-installed", async (_event, payload: any) => {
ipcMain.on("remove-installed", async (_event, payload) => { ipcMain.on("remove-installed", async (_event, payload) => {
const webContents = _event.sender; const webContents = _event.sender;
const pkgname = typeof payload === "string" ? payload : payload.pkgname; const parsed = parseAppPayload(payload);
const origin = typeof payload === "string" ? "spark" : payload.origin; if (!parsed) {
logger.warn("remove-installed invalid payload");
if (!pkgname) {
logger.warn("remove-installed missing pkgname");
return; return;
} }
const { pkgname, origin } = parsed;
logger.info(`卸载已安装应用: ${pkgname} (来源: ${origin})`); logger.info(`卸载已安装应用: ${pkgname} (来源: ${origin})`);
let execCommand = ""; let execCommand = "";
@@ -994,8 +1266,16 @@ ipcMain.handle(
}> = []; }> = [];
if (origin === "spark") { if (origin === "spark") {
// 如果提供了包名列表只检查这些包的安装状态(优化版) // 显式传入了包名列表(可能是空数组):只检查这些包的安装状态(优化版)
if (pkgnameList && pkgnameList.length > 0) { if (Array.isArray(pkgnameList)) {
if (pkgnameList.length === 0) {
// 商店目录尚未加载或该来源没有任何可枚举的包时,
// 直接返回空列表,避免退化为“全量扫描整个系统”后再被渲染端全部跳过,
// 否则会误报“已安装应用为空”。
logger.info("Spark 包名列表为空,跳过已安装检查");
return { success: true, apps: [] };
}
logger.info( logger.info(
`使用优化模式检查 ${pkgnameList.length} 个 Spark 包的安装状态`, `使用优化模式检查 ${pkgnameList.length} 个 Spark 包的安装状态`,
); );
@@ -1037,7 +1317,7 @@ ipcMain.handle(
return { success: true, apps: installedApps }; return { success: true, apps: installedApps };
} }
// 回退到全量扫描模式(未提供包名列表时 // 回退到全量扫描模式(仅当调用方未传入 pkgnameList 时,例如旧版直接调用
logger.info("使用全量扫描模式获取所有 Spark 已安装包"); logger.info("使用全量扫描模式获取所有 Spark 已安装包");
const { code, stdout } = await runCommandCapture("dpkg-query", [ const { code, stdout } = await runCommandCapture("dpkg-query", [
"-W", "-W",
@@ -1231,71 +1511,93 @@ ipcMain.handle("show-apm-install-dialog", async (event) => {
return { success: false, cancelled: true }; return { success: false, cancelled: true };
}); });
// eslint-disable-next-line @typescript-eslint/no-explicit-any ipcMain.handle(
ipcMain.handle("uninstall-installed", async (_event, payload: any) => { "uninstall-installed",
const pkgname = typeof payload === "string" ? payload : payload.pkgname; async (
const origin = typeof payload === "string" ? "spark" : payload.origin; _event,
payload: unknown,
): Promise<{ success: boolean; message?: string }> => {
const parsed = parseAppPayload(payload);
if (!parsed) {
logger.warn("uninstall-installed invalid payload");
return { success: false, message: "invalid payload" };
}
const { pkgname, origin } = parsed;
if (!pkgname) { const superUserCmd = await checkSuperUserCommand();
logger.warn("uninstall-installed missing pkgname"); const execCommand = superUserCmd || SHELL_CALLER_PATH;
return { success: false, message: "missing pkgname" }; const execParams = superUserCmd ? [SHELL_CALLER_PATH] : [];
}
const superUserCmd = await checkSuperUserCommand(); if (origin === "apm") {
const execCommand = superUserCmd || SHELL_CALLER_PATH; execParams.push("apm", "remove", "-y", pkgname);
const execParams = superUserCmd ? [SHELL_CALLER_PATH] : []; } else {
execParams.push("aptss", "remove", "-y", pkgname);
}
if (origin === "apm") { const { code, stdout, stderr } = await runCommandCapture(
execParams.push("apm", "remove", "-y", pkgname); execCommand,
} else { execParams,
execParams.push("aptss", "remove", "-y", pkgname); );
} const success = code === 0;
const { code, stdout, stderr } = await runCommandCapture( if (success) {
execCommand, logger.info(`卸载完成: ${pkgname}`);
execParams, } else {
); logger.error(`卸载失败: ${pkgname} ${stderr || stdout}`);
const success = code === 0; }
if (success) { return {
logger.info(`卸载完成: ${pkgname}`); success,
} else { message: success
logger.error(`卸载失败: ${pkgname} ${stderr || stdout}`); ? "卸载完成"
} : stderr || stdout || `卸载失败,退出码 ${code}`,
};
},
);
return { interface LaunchAppPayload {
success, pkgname: string;
message: success origin?: "spark" | "apm";
? "卸载完成" }
: stderr || stdout || `卸载失败,退出码 ${code}`,
};
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any ipcMain.handle(
ipcMain.handle("launch-app", async (_event, payload: any) => { "launch-app",
const pkgname = typeof payload === "string" ? payload : payload.pkgname; async (
const origin = typeof payload === "string" ? "spark" : payload.origin; _event,
payload: LaunchAppPayload,
): Promise<{ success: boolean; message?: string }> => {
const pkgname = typeof payload === "string" ? payload : payload.pkgname;
const origin = typeof payload === "string" ? "spark" : payload.origin;
if (!pkgname) { if (
logger.warn("No pkgname provided for launch-app"); !pkgname ||
} typeof pkgname !== "string" ||
!PKGNAME_PATTERN.test(pkgname) ||
pkgname.length > 256
) {
logger.warn(`Invalid pkgname provided for launch-app: ${pkgname}`);
return { success: false, message: "Invalid package name" };
}
let execCommand = "/opt/spark-store/extras/app-launcher"; let execCommand = "/opt/spark-store/extras/app-launcher";
let execParams = ["start", pkgname]; let execParams = ["start", pkgname];
if (origin === "apm") { if (origin === "apm") {
execCommand = "apm"; execCommand = "apm";
execParams = ["launch", pkgname]; execParams = ["launch", pkgname];
} }
logger.info( logger.info(
`Launching app: ${pkgname} with command: ${execCommand} ${execParams.join(" ")}`, `Launching app: ${pkgname} with command: ${execCommand} ${execParams.join(" ")}`,
); );
spawn(execCommand, execParams, { spawn(execCommand, execParams, {
shell: false, shell: false,
env: process.env, env: process.env,
detached: true, detached: true,
stdio: "ignore", stdio: "ignore",
}).unref(); }).unref();
});
return { success: true };
},
);
+29 -6
View File
@@ -448,6 +448,7 @@ export function registerSubmitterHandlers(
width: 800, width: 800,
height: 900, height: 900,
frame: false, frame: false,
transparent: true,
show: false, show: false,
autoHideMenuBar: true, autoHideMenuBar: true,
icon: path.join(process.env.VITE_PUBLIC!, "favicon.ico"), icon: path.join(process.env.VITE_PUBLIC!, "favicon.ico"),
@@ -972,6 +973,24 @@ export function registerSubmitterHandlers(
} }
}); });
ipcMain.handle("get-git-name", async () => {
try {
const { exec } = await import("node:child_process");
const util = await import("util");
const execAsync = util.promisify(exec);
const { stdout } = await execAsync("git config user.name");
const name = stdout.trim();
logger.info({ name }, "[Submitter] Git name retrieved");
return { success: true, data: name || "" };
} catch (err) {
logger.warn(
{ err },
"[Submitter] Failed to get git name, not a git repo or git not installed",
);
return { success: false, data: "" };
}
});
ipcMain.handle("get-tags-list", async () => { ipcMain.handle("get-tags-list", async () => {
try { try {
const apiUrl = "https://upload.deepinos.org.cn/api/index/get_tags_list"; const apiUrl = "https://upload.deepinos.org.cn/api/index/get_tags_list";
@@ -1402,8 +1421,9 @@ export function registerSubmitterHandlers(
// arch 由前端解析 deb 时获取并传入,无需再次调用 dpkg-deb // arch 由前端解析 deb 时获取并传入,无需再次调用 dpkg-deb
const debArch = String(dataObj.arch || "amd64"); const debArch = String(dataObj.arch || "amd64");
const pkgVersion = String(dataObj.version || "0.0.0"); const pkgVersion = String(dataObj.version || "0.0.0");
const debPkgName = String(dataObj.pkgname || "unknown"); const debPkgName = String(dataObj.pkgname || "unknown").toLowerCase();
const formFileName = `${debPkgName}_${pkgVersion}_${debArch}.deb`; const formFileName =
`${debPkgName}_${pkgVersion}_${debArch}.deb`.toLowerCase();
logger.info( logger.info(
{ formFileName, debArch }, { formFileName, debArch },
"[Submitter] Constructed file_name", "[Submitter] Constructed file_name",
@@ -1671,7 +1691,7 @@ export function registerSubmitterHandlers(
} }
const dataObj = formData as Record<string, unknown>; const dataObj = formData as Record<string, unknown>;
const pkgname = String(dataObj.pkgname || ""); const pkgname = String(dataObj.pkgname || "").toLowerCase();
const packageName = String(dataObj.name || ""); const packageName = String(dataObj.name || "");
const packageVersion = String(dataObj.version || ""); const packageVersion = String(dataObj.version || "");
const packageCategory = String(dataObj.category || ""); const packageCategory = String(dataObj.category || "");
@@ -1862,7 +1882,8 @@ export function registerSubmitterHandlers(
"[Submitter] Deb metadata parsed", "[Submitter] Deb metadata parsed",
); );
const packDebFileName = `${debPkgName}_${debVersion}_${debArch}.deb`; const packDebFileName =
`${debPkgName}_${debVersion}_${debArch}.deb`.toLowerCase();
logger.info( logger.info(
{ {
from: debFilePath, from: debFilePath,
@@ -1913,7 +1934,7 @@ export function registerSubmitterHandlers(
sendPackageProgress("tar", 85, "正在打包 tar.gz..."); sendPackageProgress("tar", 85, "正在打包 tar.gz...");
logger.info("[Submitter] Starting tar..."); logger.info("[Submitter] Starting tar...");
const tarFileName = `${pkgname}-${storeArch}.tar.gz`; const tarFileName = `${pkgname.toLowerCase()}-${storeArch.toLowerCase()}.tar.gz`;
const tarOutputPath = path.join(baseTempDir, tarFileName); const tarOutputPath = path.join(baseTempDir, tarFileName);
try { try {
await execAsync( await execAsync(
@@ -1938,7 +1959,9 @@ export function registerSubmitterHandlers(
sendPackageProgress("done", 100, "打包完成!"); sendPackageProgress("done", 100, "打包完成!");
logger.info({ baseTempDir }, "[Submitter] Opening folder"); logger.info({ baseTempDir }, "[Submitter] Opening folder");
await shell.openPath(baseTempDir); shell.openPath(baseTempDir).catch((err) => {
logger.warn({ err }, "[Submitter] Failed to open folder");
});
const duration = Date.now() - startTime; const duration = Date.now() - startTime;
logger.info( logger.info(
+191 -126
View File
@@ -1,7 +1,10 @@
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import pino from "pino";
import { BrowserWindow, ipcMain } from "electron"; import { BrowserWindow, ipcMain } from "electron";
const logger = pino({ name: "updateCenter" });
import { SHELL_CALLER_PATH } from "../shared-installer"; import { SHELL_CALLER_PATH } from "../shared-installer";
import { findExecutable, SUPER_USER_COMMAND_CANDIDATES } from "../superuser"; import { findExecutable, SUPER_USER_COMMAND_CANDIDATES } from "../superuser";
import { import {
@@ -165,9 +168,9 @@ const loadAptssItemMetadata = async (
| { item: UpdateCenterItem; warning?: undefined } | { item: UpdateCenterItem; warning?: undefined }
| { item: null; warning: string } | { 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); const printUrisCommand = getAptssPrintUrisCommand(item.pkgname);
console.log( logger.debug(
`[DEBUG] APTSS command: ${printUrisCommand.command} ${printUrisCommand.args.join(" ")}`, `[DEBUG] APTSS command: ${printUrisCommand.command} ${printUrisCommand.args.join(" ")}`,
); );
@@ -175,11 +178,11 @@ const loadAptssItemMetadata = async (
printUrisCommand.command, printUrisCommand.command,
printUrisCommand.args, printUrisCommand.args,
); );
console.log(`[DEBUG] APTSS metadata result code: ${metadataResult.code}`); logger.debug(`[DEBUG] APTSS metadata result code: ${metadataResult.code}`);
console.log( logger.debug(
`[DEBUG] APTSS metadata stdout: ${metadataResult.stdout.substring(0, 500)}`, `[DEBUG] APTSS metadata stdout: ${metadataResult.stdout.substring(0, 500)}`,
); );
console.log( logger.debug(
`[DEBUG] APTSS metadata stderr: ${metadataResult.stderr.substring(0, 500)}`, `[DEBUG] APTSS metadata stderr: ${metadataResult.stderr.substring(0, 500)}`,
); );
@@ -188,18 +191,18 @@ const loadAptssItemMetadata = async (
metadataResult, metadataResult,
); );
if (commandError) { if (commandError) {
console.log(`[DEBUG] APTSS metadata error: ${commandError}`); logger.debug(`[DEBUG] APTSS metadata error: ${commandError}`);
return { item: null, warning: commandError }; return { item: null, warning: commandError };
} }
const metadata = parsePrintUrisOutput(metadataResult.stdout); const metadata = parsePrintUrisOutput(metadataResult.stdout);
if (metadata) { if (metadata) {
console.log(`[DEBUG] APTSS parsed metadata:`, { logger.debug(`[DEBUG] APTSS parsed metadata:`, {
...metadata, ...metadata,
downloadUrl: `${metadata.downloadUrl}.metalink`, downloadUrl: `${metadata.downloadUrl}.metalink`,
}); });
} else { } else {
console.log(`[DEBUG] APTSS parsed metadata:`, metadata); logger.debug(`[DEBUG] APTSS parsed metadata:`, metadata);
} }
if (!metadata) { if (!metadata) {
@@ -378,7 +381,7 @@ export const loadUpdateCenterItems = async (
storeFilter: StoreFilter = "both", storeFilter: StoreFilter = "both",
runCommand: UpdateCenterCommandRunner = runCommandCapture, runCommand: UpdateCenterCommandRunner = runCommandCapture,
): Promise<UpdateCenterLoadItemsResult> => { ): Promise<UpdateCenterLoadItemsResult> => {
console.log( logger.debug(
`[UpdateCenter] loadUpdateCenterItems called with storeFilter=${storeFilter}`, `[UpdateCenter] loadUpdateCenterItems called with storeFilter=${storeFilter}`,
); );
const [sparkEnabled, apmEnabled] = await Promise.all([ const [sparkEnabled, apmEnabled] = await Promise.all([
@@ -389,7 +392,7 @@ export const loadUpdateCenterItems = async (
? isCommandAvailable(runCommand, "apm") ? isCommandAvailable(runCommand, "apm")
: Promise.resolve(false), : Promise.resolve(false),
]); ]);
console.log( logger.debug(
`[UpdateCenter] sparkEnabled=${sparkEnabled}, apmEnabled=${apmEnabled}`, `[UpdateCenter] sparkEnabled=${sparkEnabled}, apmEnabled=${apmEnabled}`,
); );
@@ -415,16 +418,16 @@ export const loadUpdateCenterItems = async (
: Promise.resolve({ code: 0, stdout: "", stderr: "" }), : Promise.resolve({ code: 0, stdout: "", stderr: "" }),
]); ]);
console.log( logger.debug(
`[UpdateCenter] aptssResult: code=${aptssResult.code}, stdout=${aptssResult.stdout.substring(0, 500)}, stderr=${aptssResult.stderr.substring(0, 500)}`, `[UpdateCenter] aptssResult: code=${aptssResult.code}, stdout=${aptssResult.stdout.substring(0, 500)}, stderr=${aptssResult.stderr.substring(0, 500)}`,
); );
console.log( logger.debug(
`[UpdateCenter] apmResult: code=${apmResult.code}, stdout=${apmResult.stdout.substring(0, 500)}, stderr=${apmResult.stderr.substring(0, 500)}`, `[UpdateCenter] apmResult: code=${apmResult.code}, stdout=${apmResult.stdout.substring(0, 500)}, stderr=${apmResult.stderr.substring(0, 500)}`,
); );
console.log( logger.debug(
`[UpdateCenter] aptssInstalledResult: code=${aptssInstalledResult.code}, stdout=${aptssInstalledResult.stdout.substring(0, 500)}`, `[UpdateCenter] aptssInstalledResult: code=${aptssInstalledResult.code}, stdout=${aptssInstalledResult.stdout.substring(0, 500)}`,
); );
console.log( logger.debug(
`[UpdateCenter] apmInstalledResult: code=${apmInstalledResult.code}, stdout=${apmInstalledResult.stdout.substring(0, 500)}`, `[UpdateCenter] apmInstalledResult: code=${apmInstalledResult.code}, stdout=${apmInstalledResult.stdout.substring(0, 500)}`,
); );
@@ -452,11 +455,11 @@ export const loadUpdateCenterItems = async (
apmEnabled && apmResult.code === 0 apmEnabled && apmResult.code === 0
? parseApmUpgradableOutput(apmResult.stdout) ? parseApmUpgradableOutput(apmResult.stdout)
: []; : [];
console.log( logger.debug(
`[UpdateCenter] parsed aptssItems count=${aptssItems.length}`, `[UpdateCenter] parsed aptssItems count=${aptssItems.length}`,
aptssItems.map((i) => `${i.pkgname} ${i.currentVersion}->${i.nextVersion}`), aptssItems.map((i) => `${i.pkgname} ${i.currentVersion}->${i.nextVersion}`),
); );
console.log( logger.debug(
`[UpdateCenter] parsed apmItems count=${apmItems.length}`, `[UpdateCenter] parsed apmItems count=${apmItems.length}`,
apmItems.map((i) => `${i.pkgname} ${i.currentVersion}->${i.nextVersion}`), apmItems.map((i) => `${i.pkgname} ${i.currentVersion}->${i.nextVersion}`),
); );
@@ -467,7 +470,7 @@ export const loadUpdateCenterItems = async (
: "", : "",
apmInstalledResult.code === 0 ? apmInstalledResult.stdout : "", apmInstalledResult.code === 0 ? apmInstalledResult.stdout : "",
); );
console.log(`[UpdateCenter] installedSources size=${installedSources.size}`); logger.debug(`[UpdateCenter] installedSources size=${installedSources.size}`);
const [categorizedAptssItems, categorizedApmItems] = await Promise.all([ const [categorizedAptssItems, categorizedApmItems] = await Promise.all([
aptssAvailable ? enrichItemCategories(aptssItems) : Promise.resolve([]), aptssAvailable ? enrichItemCategories(aptssItems) : Promise.resolve([]),
@@ -481,11 +484,11 @@ export const loadUpdateCenterItems = async (
? enrichApmItems(categorizedApmItems, runCommand) ? enrichApmItems(categorizedApmItems, runCommand)
: Promise.resolve({ items: [], warnings: [] }), : Promise.resolve({ items: [], warnings: [] }),
]); ]);
console.log( logger.debug(
`[UpdateCenter] enrichedAptssItems: count=${enrichedAptssItems.items.length}, warnings=${enrichedAptssItems.warnings.length}`, `[UpdateCenter] enrichedAptssItems: count=${enrichedAptssItems.items.length}, warnings=${enrichedAptssItems.warnings.length}`,
enrichedAptssItems.warnings, enrichedAptssItems.warnings,
); );
console.log( logger.debug(
`[UpdateCenter] enrichedApmItems: count=${enrichedApmItems.items.length}, warnings=${enrichedApmItems.warnings.length}`, `[UpdateCenter] enrichedApmItems: count=${enrichedApmItems.items.length}, warnings=${enrichedApmItems.warnings.length}`,
enrichedApmItems.warnings, enrichedApmItems.warnings,
); );
@@ -495,15 +498,19 @@ export const loadUpdateCenterItems = async (
enrichItemIcons(enrichedApmItems.items), enrichItemIcons(enrichedApmItems.items),
installedSources, installedSources,
); );
console.log( logger.debug(
`[UpdateCenter] mergedItems count=${mergedItems.length}`, `[UpdateCenter] mergedItems count=${mergedItems.length}`,
mergedItems.map( mergedItems.map(
(i) => `${i.pkgname} (${i.source}) ${i.currentVersion}->${i.nextVersion}`, (i) => `${i.pkgname} (${i.source}) ${i.currentVersion}->${i.nextVersion}`,
), ),
); );
// 标记被系统锁定(apt-mark hold)的包:这类包被 apt 拒绝升级(除非 --allow-change-held-packages)。
// 更新中心据此默认禁用其勾选,并提示用户单独开启「强制安装」。
const heldItems = await markHeldPackages(mergedItems, runCommand);
return { return {
items: mergedItems, items: heldItems,
warnings: [ warnings: [
...warnings, ...warnings,
...enrichedAptssItems.warnings, ...enrichedAptssItems.warnings,
@@ -512,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 = ( export const registerUpdateCenterIpc = (
ipc: Pick<typeof ipcMain, "handle">, ipc: Pick<typeof ipcMain, "handle">,
service: Pick< service: Pick<
@@ -528,111 +696,8 @@ export const registerUpdateCenterIpc = (
): void => { ): void => {
ipc.handle( ipc.handle(
"update-center-run-system-update", "update-center-run-system-update",
async (_event, storeFilter: StoreFilter = "both") => { async (_event, storeFilter: StoreFilter = "both") =>
console.log( runSystemUpdateSources(storeFilter),
`[UpdateCenter] update-center-run-system-update called with storeFilter=${storeFilter}`,
);
const results: { aptss?: string; apm?: string } = {};
const runCommand = (
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 = "";
child.stdout?.on("data", (data) => {
stdout += data.toString();
});
child.stderr?.on("data", (data) => {
stderr += data.toString();
});
child.on("error", (err) =>
resolve({ code: -1, stdout, stderr: err.message }),
);
child.on("close", (code) =>
resolve({ code: code ?? -1, stdout, stderr }),
);
});
const isSourceEnabled = (
filter: StoreFilter,
source: "spark" | "apm",
): boolean => filter === "both" || filter === source;
// aptss update — 需要提权
if (isSourceEnabled(storeFilter, "spark")) {
const whichResult = await runCommand("which", ["aptss"]);
const aptssAvailable =
whichResult.code === 0 && whichResult.stdout.trim().length > 0;
if (aptssAvailable) {
console.log(
"[UpdateCenter] Running: pkexec shell-caller aptss ssupdate",
);
const superUserCmd = await findExecutable(
SUPER_USER_COMMAND_CANDIDATES[0],
);
if (superUserCmd) {
const result = await runCommand(superUserCmd, [
SHELL_CALLER_PATH,
"aptss",
"ssupdate",
]);
results.aptss =
result.code === 0
? "ok"
: `failed: ${result.stderr.substring(0, 200)}`;
console.log("[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";
}
}
// apm update — 也需要提权
if (isSourceEnabled(storeFilter, "apm")) {
const whichResult = await runCommand("which", ["apm"]);
const apmAvailable =
whichResult.code === 0 && whichResult.stdout.trim().length > 0;
if (apmAvailable) {
console.log("[UpdateCenter] Running: pkexec shell-caller apm update");
const superUserCmd = await findExecutable(
SUPER_USER_COMMAND_CANDIDATES[0],
);
if (superUserCmd) {
const result = await runCommand(superUserCmd, [
SHELL_CALLER_PATH,
"apm",
"update",
]);
results.apm =
result.code === 0
? "ok"
: `failed: ${result.stderr.substring(0, 200)}`;
console.log("[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;
},
); );
ipc.handle( ipc.handle(
@@ -1,11 +1,16 @@
import { join } from "node:path"; import { join } from "node:path";
import { tmpdir } from "node:os";
import { runAria2Download, type Aria2DownloadResult } from "./download"; import { runAria2Download, type Aria2DownloadResult } from "./download";
import { installPackage } from "../shared-installer"; import { installPackage } from "../shared-installer";
import type { UpdateCenterQueue, UpdateCenterTask } from "./queue"; import type { UpdateCenterQueue, UpdateCenterTask } from "./queue";
import type { UpdateCenterItem } from "./types"; 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 { export interface InstallUpdateItemOptions {
item: UpdateCenterItem; item: UpdateCenterItem;
+5 -1
View File
@@ -210,7 +210,11 @@ const parseUpgradableOutput = (
const arch = tokens[2] ?? ""; const arch = tokens[2] ?? "";
const currentVersion = const currentVersion =
trimmed.match(CURRENT_VERSION_PATTERN)?.[1] ?? tokens[5] ?? ""; trimmed.match(CURRENT_VERSION_PATTERN)?.[1] ?? tokens[5] ?? "";
if (!pkgname || nextVersion === currentVersion) { // 仅当包名缺失或当前版本解析失败时才跳过。
// 注意:不再因 nextVersion === currentVersion 而跳过——aptss 已判定该项为
// upgradable,应信任上游判断;否则当仓库元数据出现"同版本重新发布"等情况时,
// 真实的更新项会被无声隐藏,导致"软件更新"列表空白。
if (!pkgname || !currentVersion) {
continue; continue;
} }
+60 -19
View File
@@ -1,4 +1,8 @@
import { BrowserWindow } from "electron"; import { BrowserWindow } from "electron";
import {
addInstallTask,
type QueueInstallPayload,
} from "../install-manager";
import { import {
IGNORE_CONFIG_PATH, IGNORE_CONFIG_PATH,
applyIgnoredEntries, applyIgnoredEntries,
@@ -67,6 +71,8 @@ export interface UpdateCenterIgnorePayload {
export interface UpdateCenterStartTask { export interface UpdateCenterStartTask {
taskKey: string; taskKey: string;
id: number; id: number;
// 强制安装被系统锁定(apt-mark hold)的包
forceHeld?: boolean;
} }
export interface UpdateCenterService { export interface UpdateCenterService {
@@ -115,6 +121,7 @@ const toState = (
migrationSource: item.migrationSource, migrationSource: item.migrationSource,
migrationTarget: item.migrationTarget, migrationTarget: item.migrationTarget,
aptssVersion: item.aptssVersion, aptssVersion: item.aptssVersion,
held: item.held,
})), })),
tasks: [], // 不再展示任务日志 tasks: [], // 不再展示任务日志
warnings: [...snapshot.warnings], warnings: [...snapshot.warnings],
@@ -217,15 +224,10 @@ export const createUpdateCenterService = (
}, },
async start(tasks) { async start(tasks) {
const snapshot = queue.getSnapshot(); const snapshot = queue.getSnapshot();
const taskIdByKey = new Map(tasks.map((task) => [task.taskKey, task.id])); const taskByKey = new Map(
const selectedItems = snapshot.items.filter( tasks.map((task) => [task.taskKey, task] as const),
(item) => taskIdByKey.has(getTaskKey(item)) && !item.ignored,
); );
if (selectedItems.length === 0) {
return;
}
// 获取主窗口的 webContents // 获取主窗口的 webContents
const mainWindow = BrowserWindow.getAllWindows()[0]; const mainWindow = BrowserWindow.getAllWindows()[0];
const webContents = mainWindow?.webContents; const webContents = mainWindow?.webContents;
@@ -235,22 +237,63 @@ export const createUpdateCenterService = (
return; return;
} }
// 获取当前 items // 分类:可启动项 vs 被锁定(held)且未开启强制安装的项。
let currentItems = snapshot.items; // 被锁定的项需用户单独开启「强制安装」才能升级,否则明确告知失败,避免静默卡在「开始更新」。
const startableItems: typeof snapshot.items = [];
const heldBlocked: Array<{
item: (typeof snapshot.items)[number];
id: number;
}> = [];
for (const item of selectedItems) { for (const item of snapshot.items) {
const updateTaskId = taskIdByKey.get(getTaskKey(item)); const updateTask = taskByKey.get(getTaskKey(item));
if (!updateTaskId) { if (!updateTask || item.ignored) continue;
if (item.held === true && !updateTask.forceHeld) {
heldBlocked.push({ item, id: updateTask.id });
continue; 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 // 构建 metalink URL
const metalinkUrl = item.downloadUrl const metalinkUrl = item.downloadUrl
? `${item.downloadUrl}.metalink` ? `${item.downloadUrl}.metalink`
: undefined; : undefined;
// 发送到主下载队列 // 直接加入主下载队列(之前用 webContents.send("queue-install") 只会发给渲染端,
const installTaskData = { // 主进程 ipcMain 监听不到自己发出的 send,导致任务实际未启动而卡死)。
const installTaskData: QueueInstallPayload = {
id: updateTaskId, id: updateTaskId,
pkgname: item.pkgname, pkgname: item.pkgname,
metalinkUrl, metalinkUrl,
@@ -258,20 +301,18 @@ export const createUpdateCenterService = (
upgradeOnly: true, upgradeOnly: true,
origin: item.source === "apm" ? "apm" : "spark", origin: item.source === "apm" ? "apm" : "spark",
retry: false, retry: false,
forceHeld: item.held === true ? forceHeld : false,
}; };
// 通过 IPC 发送到主下载队列 await addInstallTask(installTaskData, webContents);
webContents.send("queue-install", JSON.stringify(installTaskData));
// 从更新中心的 items 中移除该应用(不再显示在更新列表中) // 启动成功后从更新中心列表移除该项(被 hold 未强制拦截的项不会进入此处,仍保留)。
currentItems = currentItems.filter( currentItems = currentItems.filter(
(i) => getTaskKey(i) !== getTaskKey(item), (i) => getTaskKey(i) !== getTaskKey(item),
); );
} }
// 更新队列中的 items
queue.setItems(currentItems); queue.setItems(currentItems);
emit(); emit();
}, },
async cancel(taskKey) { async cancel(taskKey) {
@@ -24,4 +24,8 @@ export interface UpdateCenterItem {
migrationSource?: UpdateSource; migrationSource?: UpdateSource;
migrationTarget?: UpdateSource; migrationTarget?: UpdateSource;
aptssVersion?: string; aptssVersion?: string;
// 更新发布时间(毫秒时间戳);由上游解析填充,暂无则缺省
updateTime?: number;
// 是否被系统锁定(apt-mark hold)。被锁定的包默认不可批量选中,需用户单独开启「强制安装」
held?: boolean;
} }
+309 -14
View File
@@ -4,9 +4,11 @@ import {
ipcMain, ipcMain,
Menu, Menu,
nativeImage, nativeImage,
net,
shell, shell,
Tray, Tray,
nativeTheme, nativeTheme,
screen,
session, session,
} from "electron"; } from "electron";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
@@ -18,7 +20,10 @@ import { handleCommandLine } from "./deeplink.js";
import { isLoaded } from "../global.js"; import { isLoaded } from "../global.js";
import { tasks } from "./backend/install-manager.js"; import { tasks } from "./backend/install-manager.js";
import { sendTelemetryOnce } from "./backend/telemetry.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 { import {
getMainWindowCloseAction, getMainWindowCloseAction,
type MainWindowCloseGuardState, type MainWindowCloseGuardState,
@@ -72,6 +77,13 @@ if (!app.requestSingleInstanceLock()) {
import "./backend/install-manager.js"; import "./backend/install-manager.js";
import "./handle-url-scheme.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 logger = pino({ name: "index.ts" });
const FLARUM_TOKEN_URL = "https://bbs.spark-app.store/api/token"; const FLARUM_TOKEN_URL = "https://bbs.spark-app.store/api/token";
@@ -89,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 RENDERER_DIST = path.join(process.env.APP_ROOT, "dist");
export const VITE_DEV_SERVER_URL = process.env.VITE_DEV_SERVER_URL; 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 process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL
? path.join(process.env.APP_ROOT, "public") ? path.join(process.env.APP_ROOT, "public")
: RENDERER_DIST; : RENDERER_DIST;
@@ -129,7 +145,9 @@ logger.info("User Agent: " + getUserAgent());
/** 根据启动参数 --no-apm / --no-spark 决定只展示的来源 */ /** 根据启动参数 --no-apm / --no-spark 决定只展示的来源 */
function getStoreFilterFromArgv(): "spark" | "apm" | "both" { function getStoreFilterFromArgv(): "spark" | "apm" | "both" {
if (process.arch === "loong64") { if (process.arch === "loong64") {
// Currently loong64 only have spark support // Currently loong64 only have spark support,
// 但用户显式传入 --no-spark 时应允许回退到 apm
if (process.argv.includes("--no-spark")) return "apm";
return "spark"; return "spark";
} else { } else {
const argv = process.argv; const argv = process.argv;
@@ -146,6 +164,24 @@ ipcMain.handle("get-store-filter", (): "spark" | "apm" | "both" =>
getStoreFilterFromArgv(), 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-app-version", (): string => getAppVersion());
ipcMain.handle("get-system-info", (): { distro: string } => getSystemInfo()); ipcMain.handle("get-system-info", (): { distro: string } => getSystemInfo());
@@ -256,9 +292,10 @@ const requestApplicationExit = (): void => {
app.quit(); app.quit();
}; };
const showAndFocusMainWindow = (): void => { const showAndFocusMainWindow = async (): Promise<void> => {
if (!win || win.isDestroyed()) { if (!win || win.isDestroyed()) {
createWindow(); // 等待窗口创建完成,创建失败时调用方可通过异常感知
await createWindow();
return; return;
} }
@@ -270,11 +307,144 @@ const showAndFocusMainWindow = (): void => {
win.focus(); 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() { async function createWindow() {
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({ const mainWindow = new BrowserWindow({
title: "星火应用商店", title: "星火应用商店",
width: 1366, width: restoredWidth,
height: 768, height: restoredHeight,
center: true,
minWidth: MIN_WINDOW_SIZE.width,
minHeight: MIN_WINDOW_SIZE.height,
frame: false, frame: false,
autoHideMenuBar: true, autoHideMenuBar: true,
icon: path.join(process.env.VITE_PUBLIC, "favicon.ico"), icon: path.join(process.env.VITE_PUBLIC, "favicon.ico"),
@@ -290,6 +460,34 @@ async function createWindow() {
}); });
win = mainWindow; 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) { if (VITE_DEV_SERVER_URL) {
// #298 // #298
mainWindow.loadURL(VITE_DEV_SERVER_URL); mainWindow.loadURL(VITE_DEV_SERVER_URL);
@@ -308,9 +506,18 @@ async function createWindow() {
logger.info("Renderer process is ready."); logger.info("Renderer process is ready.");
}); });
// Make all links open with the browser, not with the application // 外部链接统一交给系统默认浏览器打开。
// 仅限制协议为 http/https,避免 file:/javascript: 等危险协议被打开;
// 不再限制域名,保证应用官网与首页推荐的任意链接均可正常访问。
mainWindow.webContents.setWindowOpenHandler(({ url }) => { mainWindow.webContents.setWindowOpenHandler(({ url }) => {
if (url.startsWith("https:")) shell.openExternal(url); try {
const parsed = new URL(url);
if (parsed.protocol === "http:" || parsed.protocol === "https:") {
shell.openExternal(url);
}
} catch {
// 无效 URL,拒绝打开
}
return { action: "deny" }; return { action: "deny" };
}); });
// win.webContents.on('will-navigate', (event, url) => { }) #344 // win.webContents.on('will-navigate', (event, url) => { }) #344
@@ -323,6 +530,17 @@ async function createWindow() {
mainWindow.on("close", (event) => { mainWindow.on("close", (event) => {
if (allowAppExit) { if (allowAppExit) {
// 真正退出前同步保存最终窗口尺寸(防抖可能尚未触发)
// 先清除尚未触发的防抖定时器,避免旧定时器随后覆盖本次同步写入
if (saveBoundsTimer) clearTimeout(saveBoundsTimer);
const { width, height, x, y } = mainWindow.getBounds();
saveWindowState({
width,
height,
x,
y,
maximized: mainWindow.isMaximized(),
});
return; return;
} }
@@ -484,6 +702,15 @@ app.whenReady().then(() => {
// Set User-Agent for client // Set User-Agent for client
session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => { session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => {
details.requestHeaders["User-Agent"] = getUserAgent(); 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 }); callback({ cancel: false, requestHeaders: details.requestHeaders });
}); });
createWindow(); createWindow();
@@ -491,8 +718,68 @@ app.whenReady().then(() => {
initializeUpdateCenter(); initializeUpdateCenter();
// 启动后执行一次遥测(仅 Linux,不阻塞) // 启动后执行一次遥测(仅 Linux,不阻塞)
sendTelemetryOnce(getAppVersion()); 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", () => { app.on("window-all-closed", () => {
win = null; win = null;
allowAppExit = false; allowAppExit = false;
@@ -500,17 +787,17 @@ app.on("window-all-closed", () => {
}); });
app.on("second-instance", () => { app.on("second-instance", () => {
showAndFocusMainWindow(); void showAndFocusMainWindow();
}); });
app.on("activate", () => { app.on("activate", () => {
showAndFocusMainWindow(); void showAndFocusMainWindow();
}); });
app.on("will-quit", () => { app.on("will-quit", () => {
// Clean up temp dir // 清理本进程专属临时目录(PID 隔离,不影响其他实例)
logger.info("Cleaning up temp dir"); 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"); logger.info("Done, exiting");
}); });
@@ -557,7 +844,7 @@ app.whenReady().then(() => {
{ {
label: "显示主界面", label: "显示主界面",
click: () => { click: () => {
showAndFocusMainWindow(); void showAndFocusMainWindow();
}, },
}, },
{ {
@@ -576,11 +863,19 @@ app.whenReady().then(() => {
win.hide(); win.hide();
win.setSkipTaskbar(true); win.setSkipTaskbar(true);
} else { } else {
showAndFocusMainWindow(); 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 // New window example arg: new windows url
// ipcMain.handle('open-win', (_, arg) => { // ipcMain.handle('open-win', (_, arg) => {
// const childWindow = new BrowserWindow({ // const childWindow = new BrowserWindow({
+12 -1
View File
@@ -1,4 +1,9 @@
import { ipcRenderer, contextBridge, type IpcRendererEvent } from "electron"; import {
ipcRenderer,
contextBridge,
webUtils,
type IpcRendererEvent,
} from "electron";
type StoreFilter = "spark" | "apm" | "both"; type StoreFilter = "spark" | "apm" | "both";
@@ -46,6 +51,7 @@ type WindowControlBridge = {
minimize: () => void; minimize: () => void;
toggleMaximize: () => void; toggleMaximize: () => void;
close: () => void; close: () => void;
state: () => Promise<| 'normal' | 'maximized' | 'minimized'>;
}; };
type UpdateCenterStateListener = (snapshot: UpdateCenterSnapshot) => void; type UpdateCenterStateListener = (snapshot: UpdateCenterSnapshot) => void;
@@ -97,10 +103,15 @@ contextBridge.exposeInMainWorld("apm_store", {
})(), })(),
}); });
contextBridge.exposeInMainWorld("electronUtils", {
getPathForFile: (file: File): string => webUtils.getPathForFile(file),
});
contextBridge.exposeInMainWorld("windowControls", { contextBridge.exposeInMainWorld("windowControls", {
minimize: () => ipcRenderer.send("window-control-minimize"), minimize: () => ipcRenderer.send("window-control-minimize"),
toggleMaximize: () => ipcRenderer.send("window-control-toggle-maximize"), toggleMaximize: () => ipcRenderer.send("window-control-toggle-maximize"),
close: () => ipcRenderer.send("window-control-close"), close: () => ipcRenderer.send("window-control-close"),
state: () => ipcRenderer.invoke("window:state"),
} satisfies WindowControlBridge); } satisfies WindowControlBridge);
contextBridge.exposeInMainWorld("updateCenter", { contextBridge.exposeInMainWorld("updateCenter", {
+27 -1
View File
@@ -85,6 +85,32 @@ case "$command_type" in
fi 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")
# 针对 aptss 的特殊逻辑:如果是 remove 子命令,需要图形化确认 # 针对 aptss 的特殊逻辑:如果是 remove 子命令,需要图形化确认
if [[ "$2" == "remove" ]]; then if [[ "$2" == "remove" ]]; then
@@ -158,7 +184,7 @@ case "$command_type" in
elif [[ "$2" == "ssupdate" ]]; then elif [[ "$2" == "ssupdate" ]]; then
/usr/bin/aptss "${@:2}" -y 2>&1 /usr/bin/aptss "${@:2}" -y 2>&1
exit_code=$? exit_code=$?
exit $? exit $exit_code
else else
# 非 remove/install 命令,拒绝执行 # 非 remove/install 命令,拒绝执行
echo "拒绝执行 aptss 白名单外的指令" echo "拒绝执行 aptss 白名单外的指令"
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "spark-store", "name": "spark-store",
"version": "5.2.1-0", "version": "5.3.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "spark-store", "name": "spark-store",
"version": "5.2.1-0", "version": "5.3.1",
"license": "GPL-3.0", "license": "GPL-3.0",
"dependencies": { "dependencies": {
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "spark-store", "name": "spark-store",
"version": "5.2.1-0", "version": "5.3.1",
"main": "dist-electron/main/index.js", "main": "dist-electron/main/index.js",
"description": "Client for Spark App Store", "description": "Client for Spark App Store",
"author": "elysia-best <elysia-best@simplelinux.cn.eu.org>", "author": "elysia-best <elysia-best@simplelinux.cn.eu.org>",
@@ -1,15 +1,17 @@
[Unit] [Unit]
Description=Spark Store update notifier Description=Spark Store update notifier
After=apt-daily.service network.target network-online.target systemd-networkd.service NetworkManager.service connman.service After=apt-daily.service network.target network-online.target systemd-networkd.service NetworkManager.service connman.service
# 设置为 1 小时的时间窗口
StartLimitIntervalSec=1h
# 最大允许失败次数为 3 次
StartLimitBurst=3
[Service] [Service]
Type=oneshot Type=simple
RemainAfterExit=yes
ExecStart=/opt/durapps/spark-store/bin/update-upgrade/ss-update-notifier.sh ExecStart=/opt/durapps/spark-store/bin/update-upgrade/ss-update-notifier.sh
Restart=on-failure Restart=on-failure
RestartSec=15 # 可以设置为更长的重试间隔,比如 15 秒或 30 秒 # 可以设置为更长的重试间隔,比如 15 秒或 30 秒
StartLimitIntervalSec=1h # 设置为 1 小时的时间窗口 RestartSec=15
StartLimitBurst=3 # 最大允许失败次数为 3 次
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
@@ -2,8 +2,8 @@
Description=Spark Store update notifier Description=Spark Store update notifier
[Timer] [Timer]
OnCalendar=*-*-* 6,18:00 OnCalendar=*-*-* 6,12,18:00
RandomizedDelaySec=12h RandomizedDelaySec=30m
Persistent=true Persistent=true
[Install] [Install]
+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"
+545 -2321
View File
File diff suppressed because it is too large Load Diff
+134 -104
View File
@@ -26,21 +26,20 @@ const createApp = (overrides: Partial<App> = {}): App => ({
}); });
describe("InstalledAppsModal", () => { describe("InstalledAppsModal", () => {
const baseProps = {
show: true,
apps: [] as App[],
loading: false,
error: "",
warning: "",
loggedIn: false,
syncing: false,
syncMessage: "",
};
it("keeps scroll chaining inside the modal list", () => { it("keeps scroll chaining inside the modal list", () => {
const { container } = render(InstalledAppsModal, { const { container } = render(InstalledAppsModal, {
props: { props: baseProps,
show: true,
apps: [],
loading: false,
error: "",
activeOrigin: "spark",
storeFilter: "both",
sparkAvailable: true,
apmAvailable: true,
loggedIn: false,
syncing: false,
syncMessage: "",
},
}); });
expect(screen.getByText("已安装应用")).toBeTruthy(); expect(screen.getByText("已安装应用")).toBeTruthy();
@@ -52,17 +51,8 @@ describe("InstalledAppsModal", () => {
it("renders open and detail actions for a store-backed installed app", () => { it("renders open and detail actions for a store-backed installed app", () => {
render(InstalledAppsModal, { render(InstalledAppsModal, {
props: { props: {
show: true, ...baseProps,
apps: [createApp()], apps: [createApp()],
loading: false,
error: "",
activeOrigin: "spark",
storeFilter: "both",
sparkAvailable: true,
apmAvailable: true,
loggedIn: false,
syncing: false,
syncMessage: "",
}, },
}); });
@@ -70,20 +60,42 @@ describe("InstalledAppsModal", () => {
expect(screen.getByRole("button", { name: "查看详情" })).toBeTruthy(); expect(screen.getByRole("button", { name: "查看详情" })).toBeTruthy();
}); });
it("renders the spark origin tag for spark apps", () => {
render(InstalledAppsModal, {
props: {
...baseProps,
apps: [createApp({ origin: "spark", name: "Spark Notes" })],
},
});
// 精确匹配应用行内的来源标签(页头统计区也有 "Spark" 文案,getAllByText 过于宽泛)
expect(screen.getByTestId("origin-tag-spark")).toBeTruthy();
});
it("renders the APM origin tag for APM apps", () => {
render(InstalledAppsModal, {
props: {
...baseProps,
apps: [
createApp({
origin: "apm",
name: "APM Container",
pkgname: "amber-pm-container",
version: "1.0.0",
}),
],
},
});
// 精确匹配应用行内的来源标签(页头统计区也有 "APM" 文案,getAllByText 过于宽泛)
expect(screen.getByTestId("origin-tag-apm")).toBeTruthy();
});
it("emits open-app when clicking 打开", async () => { it("emits open-app when clicking 打开", async () => {
const rendered = render(InstalledAppsModal, { const rendered = render(InstalledAppsModal, {
props: { props: {
show: true, ...baseProps,
apps: [createApp()], apps: [createApp()],
loading: false,
error: "",
activeOrigin: "spark",
storeFilter: "both",
sparkAvailable: true,
apmAvailable: true,
loggedIn: false,
syncing: false,
syncMessage: "",
}, },
}); });
@@ -98,17 +110,8 @@ describe("InstalledAppsModal", () => {
it("emits open-detail when clicking 查看详情", async () => { it("emits open-detail when clicking 查看详情", async () => {
const rendered = render(InstalledAppsModal, { const rendered = render(InstalledAppsModal, {
props: { props: {
show: true, ...baseProps,
apps: [createApp()], apps: [createApp()],
loading: false,
error: "",
activeOrigin: "spark",
storeFilter: "both",
sparkAvailable: true,
apmAvailable: true,
loggedIn: false,
syncing: false,
syncMessage: "",
}, },
}); });
@@ -123,17 +126,8 @@ describe("InstalledAppsModal", () => {
it("shows 查看详情 for metadata-rich unknown-category apps", () => { it("shows 查看详情 for metadata-rich unknown-category apps", () => {
render(InstalledAppsModal, { render(InstalledAppsModal, {
props: { props: {
show: true, ...baseProps,
apps: [createApp({ category: "unknown", more: "Has store metadata" })], apps: [createApp({ category: "unknown", more: "Has store metadata" })],
loading: false,
error: "",
activeOrigin: "spark",
storeFilter: "both",
sparkAvailable: true,
apmAvailable: true,
loggedIn: false,
syncing: false,
syncMessage: "",
}, },
}); });
@@ -143,17 +137,8 @@ describe("InstalledAppsModal", () => {
it("hides 查看详情 for unknown-category apps", () => { it("hides 查看详情 for unknown-category apps", () => {
render(InstalledAppsModal, { render(InstalledAppsModal, {
props: { props: {
show: true, ...baseProps,
apps: [createApp({ category: "unknown" })], apps: [createApp({ category: "unknown" })],
loading: false,
error: "",
activeOrigin: "spark",
storeFilter: "both",
sparkAvailable: true,
apmAvailable: true,
loggedIn: false,
syncing: false,
syncMessage: "",
}, },
}); });
@@ -162,19 +147,7 @@ describe("InstalledAppsModal", () => {
it("requests login for cloud actions when logged out", async () => { it("requests login for cloud actions when logged out", async () => {
const rendered = render(InstalledAppsModal, { const rendered = render(InstalledAppsModal, {
props: { props: baseProps,
show: true,
apps: [],
loading: false,
error: "",
activeOrigin: "spark",
storeFilter: "both",
sparkAvailable: true,
apmAvailable: true,
loggedIn: false,
syncing: false,
syncMessage: "",
},
}); });
await fireEvent.click(screen.getByRole("button", { name: "同步到账号" })); await fireEvent.click(screen.getByRole("button", { name: "同步到账号" }));
@@ -186,17 +159,8 @@ describe("InstalledAppsModal", () => {
it("emits cloud sync and restore events when logged in", async () => { it("emits cloud sync and restore events when logged in", async () => {
const rendered = render(InstalledAppsModal, { const rendered = render(InstalledAppsModal, {
props: { props: {
show: true, ...baseProps,
apps: [],
loading: false,
error: "",
activeOrigin: "spark",
storeFilter: "both",
sparkAvailable: true,
apmAvailable: true,
loggedIn: true, loggedIn: true,
syncing: false,
syncMessage: "",
}, },
}); });
@@ -210,17 +174,9 @@ describe("InstalledAppsModal", () => {
it("disables sync button while syncing", () => { it("disables sync button while syncing", () => {
render(InstalledAppsModal, { render(InstalledAppsModal, {
props: { props: {
show: true, ...baseProps,
apps: [],
loading: false,
error: "",
activeOrigin: "spark",
storeFilter: "both",
sparkAvailable: true,
apmAvailable: true,
loggedIn: true, loggedIn: true,
syncing: true, syncing: true,
syncMessage: "",
}, },
}); });
@@ -230,20 +186,94 @@ describe("InstalledAppsModal", () => {
it("shows account sync feedback in the installed apps modal", () => { it("shows account sync feedback in the installed apps modal", () => {
render(InstalledAppsModal, { render(InstalledAppsModal, {
props: { props: {
show: true, ...baseProps,
apps: [],
loading: false,
error: "",
activeOrigin: "spark",
storeFilter: "both",
sparkAvailable: true,
apmAvailable: true,
loggedIn: true, loggedIn: true,
syncing: false,
syncMessage: "同步完成", syncMessage: "同步完成",
}, },
}); });
expect(screen.getByText("同步完成")).toBeTruthy(); expect(screen.getByText("同步完成")).toBeTruthy();
}); });
it("filters installed apps by search query (name match)", async () => {
render(InstalledAppsModal, {
props: {
...baseProps,
apps: [
createApp({ name: "Spark Notes", pkgname: "spark-notes" }),
createApp({
name: "Visual Studio Code",
pkgname: "code",
category: "dev",
more: "https://code.visualstudio.com/",
}),
],
},
});
// 初始两条都在
expect(screen.getByText("Spark Notes")).toBeTruthy();
expect(screen.getByText("Visual Studio Code")).toBeTruthy();
const input = screen.getByPlaceholderText("搜索已安装应用…");
await fireEvent.update(input, "code");
// "code" 只匹配到 pkgname 为 "code" 的项(名称不区分大小写)
expect(screen.queryByText("Spark Notes")).toBeNull();
expect(screen.getByText("Visual Studio Code")).toBeTruthy();
});
it("filters installed apps by search query (case-insensitive)", async () => {
render(InstalledAppsModal, {
props: {
...baseProps,
apps: [
createApp({ name: "钉钉", pkgname: "com.alibaba.dingtalk" }),
],
},
});
const input = screen.getByPlaceholderText("搜索已安装应用…");
await fireEvent.update(input, "DINGTALK");
// 包名大写不区分大小写匹配
expect(screen.getByText("钉钉")).toBeTruthy();
});
it("shows a no-match hint when search query has no results", async () => {
render(InstalledAppsModal, {
props: {
...baseProps,
apps: [createApp({ name: "Spark Notes" })],
},
});
const input = screen.getByPlaceholderText("搜索已安装应用…");
await fireEvent.update(input, "不存在的关键字xyz");
expect(screen.queryByText("Spark Notes")).toBeNull();
expect(screen.getByText(/未找到匹配/)).toBeTruthy();
});
it("clears the search when clicking the clear button", async () => {
render(InstalledAppsModal, {
props: {
...baseProps,
apps: [createApp()],
},
});
const input = screen.getByPlaceholderText(
"搜索已安装应用…",
) as HTMLInputElement;
await fireEvent.update(input, "code");
expect(input.value).toBe("code");
// 清除按钮存在(仅在有内容时显示)
const clearBtn = screen.getByRole("button", { name: "清除搜索" });
await fireEvent.click(clearBtn);
expect(input.value).toBe("");
expect(screen.getByText("Spark Notes")).toBeTruthy();
});
}); });
+60 -2
View File
@@ -13,6 +13,9 @@
--color-card-light: #ffffff; --color-card-light: #ffffff;
--color-card-dark: #151c2c; --color-card-dark: #151c2c;
--color-scrollbar-thumb-light: #94a3b8;
--color-scrollbar-thumb-dark: #15203b;
--shadow-glass: 0 10px 30px rgba(15,23,42,0.08); --shadow-glass: 0 10px 30px rgba(15,23,42,0.08);
--shadow-glassDark: 0 20px 45px rgba(0,0,0,0.45); --shadow-glassDark: 0 20px 45px rgba(0,0,0,0.45);
@@ -22,6 +25,14 @@
@variant dark (&:where(.dark, .dark *)); @variant dark (&:where(.dark, .dark *));
@layer base { @layer base {
html {
color-scheme: light;
}
html.dark {
color-scheme: dark;
}
body { body {
font-family: var(--font-sans); font-family: var(--font-sans);
background-color: var(--color-surface-light); background-color: var(--color-surface-light);
@@ -38,12 +49,50 @@
#app { #app {
min-height: 100vh; min-height: 100vh;
} }
/* 全局滚动条基础几何:置于 base,使 utilities 中的 .scrollbar-muted/.scrollbar-nowidth 可覆盖 */
*::-webkit-scrollbar {
width: 0.75rem;
height: 0.75rem;
}
*::-webkit-scrollbar-track {
background: transparent;
}
/* 亮色滚动条:与暗色保持相同几何(透明边距),确保亮/暗滚动块大小一致 */
html ::-webkit-scrollbar-thumb {
background-color: var(--color-scrollbar-thumb-light);
border-radius: 9999px;
border: 2px solid transparent;
background-clip: padding-box;
}
html ::-webkit-scrollbar-track {
background: transparent;
}
/* 暗色滚动条:挂在 html.dark 上(root),比 surface 略亮以保留可辨识反差 (#15203b),
注意:必须用 html.dark 而非 .dark *,因为经典滚动条只作用于滚动容器自身,
.dark * 在亮色模式不命中会导致回退到原生宽度,亮/暗宽度不一致。 */
html.dark ::-webkit-scrollbar-thumb {
background-color: var(--color-scrollbar-thumb-dark);
border-radius: 9999px;
border: 2px solid transparent;
background-clip: padding-box;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.15);
}
html.dark ::-webkit-scrollbar-track {
background: transparent;
}
} }
@layer utilities { @layer utilities {
/* 弱化滚动条 */
.scrollbar-muted { .scrollbar-muted {
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: #94a3b8 transparent; scrollbar-color: var(--color-scrollbar-thumb-light) transparent;
} }
.scrollbar-muted::-webkit-scrollbar { .scrollbar-muted::-webkit-scrollbar {
@@ -55,10 +104,19 @@
} }
.scrollbar-muted::-webkit-scrollbar-thumb { .scrollbar-muted::-webkit-scrollbar-thumb {
background-color: #94a3b8; background-color: var(--color-scrollbar-thumb-light);
border-radius: 9999px; border-radius: 9999px;
} }
html.dark .scrollbar-muted {
scrollbar-color: var(--color-surface-dark) transparent;
}
html.dark .scrollbar-muted::-webkit-scrollbar-thumb {
background-color: var(--color-surface-dark);
}
/* 完全隐藏滚动条 */
.scrollbar-nowidth { .scrollbar-nowidth {
scrollbar-width: none; scrollbar-width: none;
} }
+3 -3
View File
@@ -52,10 +52,10 @@ const emit = defineEmits<{
display: flex; display: flex;
width: 100%; width: 100%;
align-items: center; align-items: center;
gap: 0.75rem; gap: 12px;
min-width: 0; min-width: 0;
border-radius: 0.75rem; border-radius: 12px;
padding: 0.625rem 0.75rem; padding: 10px 12px;
text-align: left; text-align: left;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 500; font-weight: 500;
+54 -5
View File
@@ -1,10 +1,24 @@
<template> <template>
<div <div
@click="openDetail" @click="openDetail"
class="group flex cursor-pointer gap-3 rounded-xl border border-slate-200/70 bg-white/90 p-4 shadow-sm transition hover:-translate-y-0.5 hover:border-brand/50 hover:shadow-md dark:border-slate-800/60 dark:bg-slate-900/60" class="group relative flex cursor-pointer transition hover:border-brand/50"
:class="
compact
? 'gap-2 rounded-lg border border-slate-200/70 bg-white/90 p-2 shadow-none hover:shadow-sm dark:border-slate-800/60 dark:bg-slate-900/60'
: 'gap-3 rounded-xl border border-slate-200/70 bg-white/90 p-4 shadow-sm hover:-translate-y-0.5 hover:shadow-md dark:border-slate-800/60 dark:bg-slate-900/60'
"
> >
<!-- 排名徽标排行榜/荣耀榜使用 3 名金// -->
<div <div
class="flex h-14 w-14 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-gradient-to-b from-slate-100 to-slate-200 shadow-inner dark:from-slate-800 dark:to-slate-700" v-if="rank"
class="pointer-events-none absolute -left-1.5 -top-1.5 z-10 flex h-6 w-6 items-center justify-center rounded-full text-xs font-bold shadow-md ring-2 ring-white transition-transform duration-200 group-hover:scale-110 dark:ring-slate-900"
:class="rankClass"
>
{{ rank }}
</div>
<div
class="flex shrink-0 items-center justify-center overflow-hidden bg-gradient-to-b from-slate-100 to-slate-200 shadow-inner dark:from-slate-800 dark:to-slate-700"
:class="compact ? 'h-9 w-9 rounded-md' : 'h-14 w-14 rounded-xl'"
> >
<img <img
ref="iconImg" ref="iconImg"
@@ -17,7 +31,8 @@
<div class="flex flex-1 flex-col gap-1 overflow-hidden"> <div class="flex flex-1 flex-col gap-1 overflow-hidden">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div <div
class="truncate text-base font-semibold text-slate-900 dark:text-white" class="truncate font-semibold text-slate-900 dark:text-white"
:class="compact ? 'text-xs' : 'text-base'"
> >
{{ app.name || "" }} {{ app.name || "" }}
</div> </div>
@@ -43,12 +58,21 @@
</span> </span>
</template> </template>
</div> </div>
<span
v-if="downloadCount"
class="ml-auto shrink-0 text-[10px] text-slate-400 dark:text-slate-500"
>{{ formatDownloads(downloadCount) }}</span
>
</div> </div>
<div class="text-sm text-slate-500 dark:text-slate-400 leading-tight"> <div
class="leading-tight text-slate-500 dark:text-slate-400"
:class="compact ? 'text-xs' : 'text-sm'"
>
{{ app.pkgname || "" }} · {{ app.version || "" }} {{ app.pkgname || "" }} · {{ app.version || "" }}
</div> </div>
<div <div
class="truncate text-xs text-slate-500 dark:text-slate-400 leading-tight" class="truncate leading-tight text-slate-500 dark:text-slate-400"
:class="compact ? 'text-[11px]' : 'text-xs'"
> >
{{ description || "\u00A0" }} {{ description || "\u00A0" }}
</div> </div>
@@ -65,6 +89,12 @@ const props = defineProps<{
app: App; app: App;
// 是否显示来源标识(仅在混合模式下显示) // 是否显示来源标识(仅在混合模式下显示)
showOrigin?: boolean; showOrigin?: boolean;
// 紧凑模式(用于首页精选板块等空间受限区域)
compact?: boolean;
// 下载量(用于下载排行;>=0 时在标题行右侧显示)
downloadCount?: number;
// 排名(用于排行榜/荣耀榜,左上角显示徽标)
rank?: number;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@@ -111,6 +141,25 @@ const description = computed(() => {
return more.substring(0, 80) + (more.length > 80 ? "..." : ""); return more.substring(0, 80) + (more.length > 80 ? "..." : "");
}); });
// 下载量格式化:>=1万 显示 "x.x万"
const formatDownloads = (n?: number): string => {
if (!n || n <= 0) return "0";
if (n >= 10000) {
const wan = n / 10000;
return `${wan.toFixed(1).replace(/\.0$/, "")}`;
}
return n.toLocaleString();
};
// 排名徽标配色(前 3 名金/银/铜,其余灰色)
const rankClass = computed(() => {
if (props.rank === 1) return "bg-amber-400 text-white shadow-amber-400/40";
if (props.rank === 2)
return "bg-slate-300 text-slate-700 shadow-slate-400/40";
if (props.rank === 3) return "bg-amber-600 text-white shadow-amber-600/40";
return "bg-slate-500/90 text-white shadow-slate-500/30";
});
const openDetail = () => { const openDetail = () => {
emit("open-detail", props.app); emit("open-detail", props.app);
}; };
+215 -28
View File
@@ -97,6 +97,7 @@
<button <button
v-if="app.sparkApp" v-if="app.sparkApp"
type="button" type="button"
title="原生安装,高手自定义"
class="px-3 py-1 text-xs font-medium uppercase tracking-wider transition-colors" class="px-3 py-1 text-xs font-medium uppercase tracking-wider transition-colors"
:class=" :class="
viewingOrigin === 'spark' viewingOrigin === 'spark'
@@ -110,6 +111,7 @@
<button <button
v-if="app.apmApp" v-if="app.apmApp"
type="button" type="button"
title="兼容安装,省心不折腾"
class="px-3 py-1 text-xs font-medium uppercase tracking-wider transition-colors" class="px-3 py-1 text-xs font-medium uppercase tracking-wider transition-colors"
:class=" :class="
viewingOrigin === 'apm' viewingOrigin === 'apm'
@@ -159,13 +161,7 @@
<button <button
type="button" type="button"
class="flex-1 inline-flex items-center justify-center gap-2 rounded-xl bg-slate-800 px-4 py-2.5 text-sm font-medium text-white shadow-sm transition hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600" class="flex-1 inline-flex items-center justify-center gap-2 rounded-xl bg-slate-800 px-4 py-2.5 text-sm font-medium text-white shadow-sm transition hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
@click=" @click="onOpenClick"
emit(
'open-app',
displayApp?.pkgname || '',
displayApp?.origin,
)
"
> >
<i class="fas fa-external-link-alt text-xs"></i> <i class="fas fa-external-link-alt text-xs"></i>
<span>打开</span> <span>打开</span>
@@ -300,7 +296,7 @@
</h3> </h3>
<div <div
class="text-sm leading-relaxed text-slate-600 dark:text-slate-300 space-y-2" class="text-sm leading-relaxed text-slate-600 dark:text-slate-300 space-y-2"
v-html="displayApp.more.replace(/\n/g, '<br>')" v-html="sanitizeMoreContent(displayApp.more)"
></div> ></div>
</div> </div>
<div <div
@@ -506,6 +502,69 @@
</div> </div>
</div> </div>
</Transition> </Transition>
<!-- 双来源安装打开时选择 APM 还是 Spark -->
<Transition
enter-active-class="duration-200 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="duration-150 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-if="openChoiceVisible"
class="fixed inset-0 z-[85] flex items-center justify-center bg-slate-900/70 p-4"
@click.self="openChoiceVisible = false"
>
<div
class="relative w-full max-w-sm overflow-hidden rounded-3xl border border-white/10 bg-white/95 p-6 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
>
<div class="mb-5 flex items-start gap-3">
<div
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-gradient-to-br from-sky-100 to-indigo-50 shadow-inner dark:from-sky-900/30 dark:to-indigo-800/20"
>
<i class="fas fa-external-link-alt text-xl text-sky-500"></i>
</div>
<div>
<h3 class="text-lg font-bold text-slate-900 dark:text-white">
打开应用
</h3>
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">
该应用同时通过 APM Spark 安装请选择要打开的来源
</p>
</div>
</div>
<div class="flex gap-3">
<button
type="button"
class="inline-flex flex-1 items-center justify-center gap-2 rounded-xl border border-amber-300/70 px-4 py-2.5 text-sm font-semibold text-amber-700 transition hover:bg-amber-50 dark:border-amber-500/40 dark:text-amber-300 dark:hover:bg-amber-500/10"
@click="confirmOpen('apm')"
>
<i class="fas fa-box-open"></i>
打开 APM
</button>
<button
type="button"
class="inline-flex flex-1 items-center justify-center gap-2 rounded-xl border border-sky-300/70 px-4 py-2.5 text-sm font-semibold text-sky-700 transition hover:bg-sky-50 dark:border-sky-500/40 dark:text-sky-300 dark:hover:bg-sky-500/10"
@click="confirmOpen('spark')"
>
<i class="fas fa-bolt"></i>
打开 Spark
</button>
</div>
<div class="mt-4 flex justify-end">
<button
type="button"
class="rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
@click="openChoiceVisible = false"
>
取消
</button>
</div>
</div>
</div>
</Transition>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -517,10 +576,28 @@ import {
APM_STORE_BASE_URL, APM_STORE_BASE_URL,
getHybridDefaultOrigin, getHybridDefaultOrigin,
} from "../global/storeConfig"; } from "../global/storeConfig";
import {
tagPriorityStrategyRef,
type TagPriorityStrategy,
} from "../global/tagPriority";
// 评论功能暂时关闭 // 评论功能暂时关闭
// import { buildReviewAppKey, buildReviewTags } from "../modules/appIdentity"; // import { buildReviewAppKey, buildReviewTags } from "../modules/appIdentity";
import type { App, AppReview, ReviewTags } from "../global/typedefinition"; import type { App, AppReview, ReviewTags } from "../global/typedefinition";
/**
* 净化后端返回的应用描述(v-html 前严格去除所有标签)。
* 使用 textContent 做 HTML 实体转义,彻底杜绝 <script>/onerror/嵌套标签/编码绕过等 XSS
* 再将 \n 转为 <br>。<br> 是纯程序生成,不含任何用户输入,可安全放入 v-html。
*/
const sanitizeMoreContent = (raw: string): string => {
if (!raw) return "";
// 用 textContent 转义所有 HTML 特殊字符(比正则替换 /<[^>]*>/g 更安全,防嵌套/编码绕过)
const stripped = document.createElement("div");
stripped.textContent = raw;
// 将 \n 转为安全的 <br>(此时 textContent 已是转义后的纯文本字符串)
return (stripped.textContent ?? "").replace(/\n/g, "<br>");
};
const attrs = useAttrs(); const attrs = useAttrs();
const props = defineProps<{ const props = defineProps<{
@@ -567,30 +644,112 @@ const closeMetaModal = () => {
}; };
const openWebsite = (url: string) => { const openWebsite = (url: string) => {
if (url) { if (!url) return;
window.open(url, "_blank"); try {
const parsed = new URL(url);
// 仅允许 http/https 协议,杜绝 javascript:/data: 等危险协议
if (parsed.protocol === "https:" || parsed.protocol === "http:") {
window.open(url, "_blank", "noopener,noreferrer");
}
} catch {
// 无效 URL,不打开
} }
}; };
// 根据「标签优先显示策略」、本地安装状态与应用的可用来源标签,计算默认展示的来源
const computeDefaultViewingOrigin = (
app: App,
strategy: TagPriorityStrategy,
sparkInstalled: boolean,
apmInstalled: boolean,
): "spark" | "apm" => {
// 父组件显式指定展示来源(如从已安装应用页按特定来源打开)时优先级最高,
// 此时强制覆盖用户标签策略与本地安装状态;否则交由下方策略逻辑决定。
// 注意:被强制的应用(forceViewingOrigin=true)在详情页保持打开期间,
// 即使设置页切换标签策略也不会实时变更其展示来源(已安装来源高于策略,属设计行为)。
if (app.forceViewingOrigin && app.viewingOrigin) return app.viewingOrigin;
// 非合并应用只有一个来源标签,直接展示该标签
if (!app.isMerged) return app.origin;
// 合并应用:available 为实际存在的来源标签
const available: Array<"spark" | "apm"> = [];
if (app.sparkApp) available.push("spark");
if (app.apmApp) available.push("apm");
if (available.length === 0) return app.origin; // 兜底
if (available.length === 1) return available[0]; // 仅有一个标签时默认展示该标签
// 本地只安装了某一个版本时,优先展开已安装的版本,保证打开应用时能直接使用
// 已安装来源(例如 WeChat 默认 APM,但本机只装了 Spark 版时默认展开 Spark)。
const installed: Array<"spark" | "apm"> = [];
if (sparkInstalled && app.sparkApp) installed.push("spark");
if (apmInstalled && app.apmApp) installed.push("apm");
if (installed.length === 1) return installed[0];
// 都安装 / 都未安装时,继续按用户策略或应用配置的优先级展示:
// - 按策略优先选择对应标签
const preferred: "spark" | "apm" | null =
strategy === "spark" ? "spark" : strategy === "apm" ? "apm" : null;
if (preferred && available.includes(preferred)) {
return preferred;
}
// 回退到应用配置的优先级策略(getHybridDefaultOrigin),使用合并应用自身的
// 规范标识(pkgname/category/tags)匹配服务器 priority-config.json,并约束在可用标签范围内
const auto = getHybridDefaultOrigin(app);
return available.includes(auto) ? auto : available[0];
};
// 进入详情页时按「标签优先显示策略」与本地安装状态计算默认来源标签。
// 同时监听 props.app、共享策略 ref 与安装状态:
// - props.app 变化(重开/切换应用)→ 始终按当前策略重算(手动切标签仅本次会话预览,不持久)
// - 策略变化(在设置页实时切换)→ 已打开且未手动切标签的详情页立即更新默认标签
// - 安装状态异步返回(如仅 Spark 已安装)→ 已打开且未手动切标签时自动切换到已安装版本
// 手动点标签页(selectOrigin)只是临时预览:置 manualOverride 标记,本次会话内保持,
// 既不被策略变化强行拉回,也不跨重开持久(重开后会按设置重算)。
// 注:initTagPriorityStrategy() 已在 App.vue 启动时统一调用,此处不再重复。
const manualOverride = ref(false); // 用户是否手动切换过标签(本次会话)
let lastPkgname: string | null = null; // 上一次计算对应的应用 pkgname,用于识别"新打开/重开"
watch( watch(
() => props.app, [
(newApp: App | null) => { () => props.app,
tagPriorityStrategyRef,
() => props.sparkInstalled,
() => props.apmInstalled,
],
([newApp, strategy, sparkInstalled, apmInstalled]: [
App | null,
TagPriorityStrategy,
boolean,
boolean,
]) => {
isIconLoaded.value = false; isIconLoaded.value = false;
if (newApp) { if (!newApp) {
if (newApp.isMerged) { // 详情页关闭:清空会话状态,确保下次打开按最新策略重算
// 若父组件已根据安装状态设置了优先展示的版本,则使用 lastPkgname = null;
// 否则根据优先级配置决定默认来源 manualOverride.value = false;
if (newApp.viewingOrigin) { return;
viewingOrigin.value = newApp.viewingOrigin; }
} else if (newApp.sparkApp) { if (newApp.pkgname !== lastPkgname) {
// 使用优先级配置决定默认来源 // 新应用(含重开后再次打开同一应用):重置手动标记,按当前策略/安装状态计算
viewingOrigin.value = getHybridDefaultOrigin(newApp.sparkApp); lastPkgname = newApp.pkgname;
} else { manualOverride.value = false;
viewingOrigin.value = "apm"; viewingOrigin.value = computeDefaultViewingOrigin(
} newApp,
} else { strategy,
viewingOrigin.value = newApp.origin; sparkInstalled,
} apmInstalled,
);
} else if (manualOverride.value || newApp.forceViewingOrigin) {
// 同一应用且用户正在手动预览 / 父组件强制来源:保留当前标签,不重算
} else {
// 同一应用、无手动预览、策略/安装状态变化 → 实时按新状态更新默认标签
viewingOrigin.value = computeDefaultViewingOrigin(
newApp,
strategy,
sparkInstalled,
apmInstalled,
);
} }
}, },
{ immediate: true }, { immediate: true },
@@ -655,8 +814,10 @@ const installBtnText = computed(() => {
const iconPath = computed(() => { const iconPath = computed(() => {
if (!displayApp.value) return ""; if (!displayApp.value) return "";
const arch = window.apm_store.arch || "amd64"; const arch = window.apm_store.arch || "amd64";
// 切换来源标签时(viewingOrigin)需动态重算架构路径,确保图标/资源跟随当前查看来源,
// 而非固定使用合并应用原始 origin,避免切换源时图标与数据错位。
const finalArch = const finalArch =
displayApp.value.origin === "spark" ? `${arch}-store` : `${arch}-apm`; viewingOrigin.value === "spark" ? `${arch}-store` : `${arch}-apm`;
return `${APM_STORE_BASE_URL}/${finalArch}/${displayApp.value.category}/${displayApp.value.pkgname}/icon.png`; return `${APM_STORE_BASE_URL}/${finalArch}/${displayApp.value.category}/${displayApp.value.pkgname}/icon.png`;
}); });
@@ -728,6 +889,30 @@ const handleRemove = () => {
} }
}; };
// 双来源安装时,"打开"需先让用户选择打开 APM 还是 Spark 版
const openChoiceVisible = ref(false);
const openOrigins = (app: App | null): Array<"spark" | "apm"> =>
app?.origins && app.origins.length > 0
? app.origins
: [app?.origin ?? "spark"];
const onOpenClick = () => {
const app = displayApp.value;
if (!app) return;
const origins = openOrigins(app);
if (origins.length > 1) {
openChoiceVisible.value = true;
} else {
emit("open-app", app.pkgname, origins[0]);
}
};
const confirmOpen = (origin: "spark" | "apm") => {
const app = displayApp.value;
if (app) emit("open-app", app.pkgname, origin);
openChoiceVisible.value = false;
};
// 收藏功能暂时关闭 // 收藏功能暂时关闭
// const handleFavorite = () => { // const handleFavorite = () => {
// if (!displayApp.value) return; // if (!displayApp.value) return;
@@ -739,6 +924,8 @@ const handleRemove = () => {
// }; // };
const selectOrigin = (origin: "spark" | "apm") => { const selectOrigin = (origin: "spark" | "apm") => {
// 手动切换标签:仅本次会话预览,不写回策略、不跨重开持久
manualOverride.value = true;
viewingOrigin.value = origin; viewingOrigin.value = origin;
emit("select-origin", origin); emit("select-origin", origin);
}; };
+2
View File
@@ -51,6 +51,7 @@
<button <button
v-if="app.sparkApp" v-if="app.sparkApp"
type="button" type="button"
title="原生安装,高手自定义"
class="px-3 py-1 text-xs font-medium uppercase tracking-wider transition-colors" class="px-3 py-1 text-xs font-medium uppercase tracking-wider transition-colors"
:class=" :class="
viewingOrigin === 'spark' viewingOrigin === 'spark'
@@ -64,6 +65,7 @@
<button <button
v-if="app.apmApp" v-if="app.apmApp"
type="button" type="button"
title="兼容安装,省心不折腾"
class="px-3 py-1 text-xs font-medium uppercase tracking-wider transition-colors" class="px-3 py-1 text-xs font-medium uppercase tracking-wider transition-colors"
:class=" :class="
viewingOrigin === 'apm' viewingOrigin === 'apm'
+10 -4
View File
@@ -19,10 +19,12 @@
<!-- 应用数量较少时使用普通网格带滚动 --> <!-- 应用数量较少时使用普通网格带滚动 -->
<div v-else-if="!loading && apps.length <= 50" class="non-virtual-scroller"> <div v-else-if="!loading && apps.length <= 50" class="non-virtual-scroller">
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4"> <div
class="grid gap-4 px-4 py-6 sm:grid-cols-2 lg:px-10 xl:grid-cols-3 2xl:grid-cols-4"
>
<AppCard <AppCard
v-for="(app, index) in apps" v-for="app in apps"
:key="index" :key="app.pkgname"
:app="app" :app="app"
:show-origin="effectiveShowOrigin" :show-origin="effectiveShowOrigin"
@open-detail="$emit('open-detail', app)" @open-detail="$emit('open-detail', app)"
@@ -182,12 +184,17 @@ const gridRows = computed(() => {
.non-virtual-scroller { .non-virtual-scroller {
height: 100%; height: 100%;
overflow-y: auto; overflow-y: auto;
margin: -24px -16px; /* 抵消父容器的 px-4 py-6 */
} }
@media (min-width: 1024px) { @media (min-width: 1024px) {
.scroller { .scroller {
margin: -24px -40px; /* 抵消父容器的 lg:px-10 */ margin: -24px -40px; /* 抵消父容器的 lg:px-10 */
} }
.non-virtual-scroller {
margin: -24px -40px; /* 抵消父容器的 lg:px-10 */
}
} }
.grid-row { .grid-row {
@@ -205,7 +212,6 @@ const gridRows = computed(() => {
flex: 1; flex: 1;
box-sizing: border-box; box-sizing: border-box;
overflow-x: hidden; overflow-x: hidden;
overflow-y: auto;
} }
:deep(.vue-recycle-scroller__item-view) { :deep(.vue-recycle-scroller__item-view) {
-108
View File
@@ -1,108 +0,0 @@
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center">
<button
type="button"
class="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl border border-slate-200/70 bg-white text-slate-500 shadow-sm transition hover:bg-slate-50 lg:hidden dark:border-slate-800/70 dark:bg-slate-900 dark:text-slate-400 dark:hover:bg-slate-800"
@click="$emit('toggle-sidebar')"
title="切换侧边栏"
>
<i class="fas fa-bars"></i>
</button>
<div class="flex w-full flex-1 items-center gap-3">
<div class="relative flex-1">
<label for="searchBox" class="sr-only">搜索应用</label>
<i
class="fas fa-search pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-slate-400"
></i>
<input
id="searchBox"
v-model="localSearchQuery"
class="w-full rounded-2xl border border-slate-200/70 bg-white/80 py-3 pl-12 pr-20 text-sm text-slate-700 shadow-sm outline-none transition placeholder:text-slate-400 focus:border-brand/50 focus:ring-4 focus:ring-brand/10 dark:border-slate-800/70 dark:bg-slate-900/60 dark:text-slate-200"
placeholder="搜索应用名 / 包名 / 标签 / 粘贴 SPK 分享链接"
@focus="handleSearchFocus"
@input="handleInput"
/>
<button
v-if="localSearchQuery"
type="button"
class="absolute right-4 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-300 transition-colors"
@click="clearSearch"
title="清除搜索"
>
<i class="fas fa-times-circle"></i>
</button>
</div>
<button
type="button"
class="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl border border-slate-200/70 bg-white text-slate-500 shadow-sm transition hover:bg-slate-50 dark:border-slate-800/70 dark:bg-slate-900 dark:text-slate-400 dark:hover:bg-slate-800"
@click="$emit('open-install-settings')"
title="安装设置"
>
<i class="fas fa-cog"></i>
</button>
<button
type="button"
class="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl border border-slate-200/70 bg-white text-slate-500 shadow-sm transition hover:bg-slate-50 dark:border-slate-800/70 dark:bg-slate-900 dark:text-slate-400 dark:hover:bg-slate-800"
@click="$emit('open-about')"
title="关于"
>
<i class="fas fa-info-circle"></i>
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from "vue";
const props = defineProps<{
searchQuery: string;
activeTab: string;
appsCount: number;
}>();
const emit = defineEmits<{
(e: "update-search", query: string): void;
(e: "search-focus"): void;
(e: "open-install-settings"): void;
(e: "open-about"): void;
(e: "toggle-sidebar"): void;
(e: "spk-link", pkgname: string): void;
}>();
const localSearchQuery = ref(props.searchQuery || "");
const handleSearchFocus = () => {
emit("search-focus");
};
const handleInput = () => {
const value = localSearchQuery.value.trim();
// 检测 SPK 分享链接: spk://store/{category}/{pkgname}
const spkMatch =
value.match(/^spk:\/\/search\/(.+)$/i) ||
value.match(/^spk:\/\/store\/[^/]+\/(.+)$/i);
if (spkMatch) {
const pkgname = spkMatch[1].trim();
if (pkgname) {
localSearchQuery.value = "";
emit("spk-link", pkgname);
return;
}
}
emit("update-search", localSearchQuery.value);
};
const clearSearch = () => {
localSearchQuery.value = "";
emit("update-search", "");
};
watch(
() => props.searchQuery,
(newVal) => {
localSearchQuery.value = newVal || "";
},
);
</script>
+27 -99
View File
@@ -1,51 +1,5 @@
<template> <template>
<div class="flex h-full flex-col gap-6"> <div class="flex h-full flex-col gap-6">
<div class="flex items-start justify-between gap-3">
<div ref="accountMenuRoot" class="relative min-w-0 flex-1">
<button
type="button"
class="flex w-full min-w-0 items-center gap-2 rounded-2xl p-1 text-left transition hover:bg-slate-100 dark:hover:bg-slate-800"
:aria-label="accountLabel"
@click="handleAccountClick"
>
<img
:src="amberLogo"
alt="星火应用商店"
class="h-11 w-11 shrink-0 rounded-2xl bg-white/70 p-2 shadow-sm ring-1 ring-slate-900/5 dark:bg-slate-800"
/>
<div data-testid="account-text" class="flex min-w-0 flex-col">
<span
class="truncate text-base font-semibold text-slate-900 dark:text-white"
>{{ accountLabel }}</span
>
<span
class="truncate text-[10px] uppercase tracking-[0.2em] text-slate-400 dark:text-slate-500"
>社区版</span
>
</div>
</button>
<AccountQuickMenu
v-if="currentUser && showAccountMenu"
@open-user-management="emitAccountAction('open-user-management')"
@open-favorites="emitAccountAction('open-favorites')"
@open-forum="emitAccountAction('open-forum')"
@edit-profile="emitAccountAction('edit-profile')"
@logout="emitAccountAction('logout')"
/>
</div>
<div class="flex items-center gap-1">
<ThemeToggle :theme-mode="themeMode" @toggle="toggleTheme" />
<button
type="button"
class="inline-flex h-10 w-10 items-center justify-center rounded-2xl text-slate-400 hover:bg-slate-100 lg:hidden dark:hover:bg-slate-800"
@click="$emit('close')"
title="关闭侧边栏"
>
<i class="fas fa-times"></i>
</button>
</div>
</div>
<div class="flex-1 space-y-1 overflow-y-auto scrollbar-muted px-1 py-1"> <div class="flex-1 space-y-1 overflow-y-auto scrollbar-muted px-1 py-1">
<button <button
type="button" type="button"
@@ -71,6 +25,29 @@
> >
</button> </button>
<!-- 排行榜 / 荣耀榜暂时隐藏入口代码保留后续按需恢复 -->
<button
v-if="false"
type="button"
class="sidebar-tab"
:class="{ 'sidebar-tab-active': activeTab === 'ranking' }"
@click="selectTab('ranking')"
>
<span class="sidebar-tab-icon"><i class="fas fa-trophy"></i></span>
<span class="sidebar-tab-label">排行榜</span>
</button>
<button
v-if="false"
type="button"
class="sidebar-tab"
:class="{ 'sidebar-tab-active': activeTab === 'honor' }"
@click="selectTab('honor')"
>
<span class="sidebar-tab-icon"><i class="fas fa-medal"></i></span>
<span class="sidebar-tab-label">荣耀榜</span>
</button>
<div <div
v-if="sidebarEntries.length > 0" v-if="sidebarEntries.length > 0"
class="my-3 border-t border-slate-100 dark:border-slate-800" class="my-3 border-t border-slate-100 dark:border-slate-800"
@@ -129,10 +106,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from "vue"; import { computed } from "vue";
import AccountQuickMenu from "./AccountQuickMenu.vue";
import ThemeToggle from "./ThemeToggle.vue";
import amberLogo from "../assets/imgs/spark-store.svg";
import type { SidebarEntry, SparkUser } from "../global/typedefinition"; import type { SidebarEntry, SparkUser } from "../global/typedefinition";
const props = defineProps<{ const props = defineProps<{
@@ -162,50 +136,6 @@ const emit = defineEmits<{
(e: "logout"): void; (e: "logout"): void;
}>(); }>();
const showAccountMenu = ref(false);
const accountMenuRoot = ref<HTMLElement | null>(null);
const accountLabel = computed(() => "星火应用商店");
const handleAccountClick = () => {
// 登录功能暂时关闭
};
const handleDocumentPointerDown = (event: MouseEvent) => {
if (!showAccountMenu.value) return;
const target = event.target;
if (target instanceof Node && accountMenuRoot.value?.contains(target)) return;
showAccountMenu.value = false;
};
onMounted(() => {
document.addEventListener("mousedown", handleDocumentPointerDown);
});
onUnmounted(() => {
document.removeEventListener("mousedown", handleDocumentPointerDown);
});
const emitAccountAction = (
action:
| "open-user-management"
| "open-favorites"
| "open-forum"
| "edit-profile"
| "logout",
) => {
showAccountMenu.value = false;
if (action === "open-user-management") emit("open-user-management");
else if (action === "open-favorites") emit("open-favorites");
else if (action === "open-forum") emit("open-forum");
else if (action === "edit-profile") emit("edit-profile");
else emit("logout");
};
const toggleTheme = () => {
emit("toggle-theme");
};
const canManageApps = computed(() => { const canManageApps = computed(() => {
return ( return (
(props.storeFilter !== "apm" && props.sparkAvailable) || (props.storeFilter !== "apm" && props.sparkAvailable) ||
@@ -216,12 +146,10 @@ const canManageApps = computed(() => {
const canOpenUpdateCenter = canManageApps; const canOpenUpdateCenter = canManageApps;
const selectTab = (tab: string) => { const selectTab = (tab: string) => {
showAccountMenu.value = false;
emit("select-tab", tab); emit("select-tab", tab);
}; };
const emitSidebarAction = (action: "list" | "update" | "submit") => { const emitSidebarAction = (action: "list" | "update" | "submit") => {
showAccountMenu.value = false;
if (action === "list") emit("list"); if (action === "list") emit("list");
else if (action === "update") emit("update"); else if (action === "update") emit("update");
else emit("submit"); else emit("submit");
@@ -233,10 +161,10 @@ const emitSidebarAction = (action: "list" | "update" | "submit") => {
display: flex; display: flex;
width: 100%; width: 100%;
align-items: center; align-items: center;
gap: 0.75rem; gap: 12px;
border: 1px solid transparent; border: 1px solid transparent;
border-radius: 0.75rem; border-radius: 12px;
padding: 0.625rem 0.875rem; padding: 10px 0.875rem;
text-align: left; text-align: left;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 500; font-weight: 500;
+3 -3
View File
@@ -71,7 +71,7 @@ const selectCategory = (category: string) => {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.5rem; gap: 0.5rem;
padding: 0.75rem 0; padding: 12px 0;
} }
.category-pill { .category-pill {
@@ -81,7 +81,7 @@ const selectCategory = (category: string) => {
white-space: nowrap; white-space: nowrap;
padding: 0.375rem 0.875rem; padding: 0.375rem 0.875rem;
border-radius: 9999px; border-radius: 9999px;
font-size: 0.8125rem; font-size: 13px;
font-weight: 500; font-weight: 500;
color: #64748b; color: #64748b;
background: #f1f5f9; background: #f1f5f9;
@@ -126,7 +126,7 @@ const selectCategory = (category: string) => {
} }
.category-pill-count { .category-pill-count {
font-size: 0.6875rem; font-size: 11px;
font-weight: 600; font-weight: 600;
opacity: 0.75; opacity: 0.75;
} }
+42 -33
View File
@@ -9,38 +9,41 @@
> >
<div <div
v-if="show" v-if="show"
class="fixed inset-0 z-50 flex items-start justify-center bg-slate-900/70 px-4 py-10" class="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/70 px-4 py-6"
@click="handleOverlayClick" @click="handleOverlayClick"
@wheel="onOverlayWheel" @wheel="onOverlayWheel"
> >
<div <div
class="scrollbar-nowidth scrollbar-thumb-slate-200 dark:scrollbar-thumb-slate-700 scrollbar-track-transparent w-full max-w-2xl max-h-[85vh] overflow-y-auto overscroll-contain rounded-3xl border border-white/10 bg-white/95 p-6 shadow-2xl dark:border-slate-800 dark:bg-slate-900" class="scrollbar-nowidth scrollbar-thumb-slate-200 dark:scrollbar-thumb-slate-700 scrollbar-track-transparent flex max-h-[92vh] w-full max-w-2xl flex-col overflow-hidden rounded-3xl border border-white/10 bg-white/95 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
@click.stop @click.stop
> >
<div class="flex items-start justify-between"> <div class="flex items-start justify-between p-5 pb-3">
<div> <div>
<p class="text-2xl font-semibold text-slate-900 dark:text-white"> <p class="text-xl font-semibold text-slate-900 dark:text-white">
下载详情 下载详情
</p> </p>
<p class="text-sm text-slate-500 dark:text-slate-400"> <p class="text-xs text-slate-500 dark:text-slate-400">
实时了解安装进度 实时了解安装进度
</p> </p>
</div> </div>
<button <button
type="button" type="button"
class="inline-flex h-10 w-10 items-center justify-center rounded-full border border-slate-200/60 text-slate-500 transition hover:text-slate-900 dark:border-slate-700" class="inline-flex h-9 w-9 items-center justify-center rounded-full border border-slate-200/60 text-slate-500 transition hover:text-slate-900 dark:border-slate-700"
@click="close" @click="close"
> >
<i class="fas fa-xmark"></i> <i class="fas fa-xmark"></i>
</button> </button>
</div> </div>
<div v-if="download" class="mt-6 space-y-6"> <div
v-if="download"
class="scrollbar-nowidth scrollbar-thumb-slate-200 dark:scrollbar-thumb-slate-700 scrollbar-track-transparent space-y-3 overflow-y-auto overscroll-contain px-5 pb-5"
>
<div <div
class="flex items-center gap-4 rounded-2xl border border-slate-200/60 p-4 dark:border-slate-800/60" class="flex items-center gap-3 rounded-2xl border border-slate-200/60 p-3 dark:border-slate-800/60"
> >
<div <div
class="h-16 w-16 overflow-hidden rounded-2xl bg-slate-100 dark:bg-slate-800" class="h-12 w-12 shrink-0 overflow-hidden rounded-2xl bg-slate-100 dark:bg-slate-800"
> >
<img <img
:src="download.icon" :src="download.icon"
@@ -48,18 +51,18 @@
class="h-full w-full object-cover" class="h-full w-full object-cover"
/> />
</div> </div>
<div class="flex-1"> <div class="min-w-0 flex-1">
<p class="text-lg font-semibold text-slate-900 dark:text-white"> <p class="truncate text-base font-semibold text-slate-900 dark:text-white">
{{ download.name }} {{ download.name }}
</p> </p>
<p class="text-sm text-slate-500 dark:text-slate-400"> <p class="truncate text-xs text-slate-500 dark:text-slate-400">
{{ download.pkgname }} · {{ download.version }} {{ download.pkgname }} · {{ download.version }}
</p> </p>
</div> </div>
</div> </div>
<div <div
class="space-y-4 rounded-2xl border border-slate-200/60 p-4 dark:border-slate-800/60" class="space-y-3 rounded-2xl border border-slate-200/60 p-3 dark:border-slate-800/60"
> >
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-sm font-medium text-slate-500">状态</span> <span class="text-sm font-medium text-slate-500">状态</span>
@@ -80,7 +83,7 @@
</span> </span>
</div> </div>
<div v-if="download.status === 'downloading'" class="space-y-3"> <div v-if="download.status === 'downloading'" class="space-y-2">
<div class="h-2 rounded-full bg-slate-100 dark:bg-slate-800"> <div class="h-2 rounded-full bg-slate-100 dark:bg-slate-800">
<div <div
class="h-full rounded-full bg-brand" class="h-full rounded-full bg-brand"
@@ -112,36 +115,42 @@
</div> </div>
<div <div
class="rounded-2xl border border-slate-200/60 p-4 text-sm text-slate-600 dark:border-slate-800/60 dark:text-slate-300" class="grid grid-cols-2 gap-x-4 gap-y-1 rounded-2xl border border-slate-200/60 p-3 text-sm text-slate-600 dark:border-slate-800/60 dark:text-slate-300"
> >
<div class="flex justify-between py-1"> <div class="flex justify-between gap-2">
<span class="text-slate-400">下载源</span> <span class="shrink-0 text-slate-400">下载源</span>
<span class="font-medium text-slate-900 dark:text-white">{{ <span class="truncate font-medium text-slate-900 dark:text-white">{{
download.origin === "spark" ? "Spark Store" : "APM Store" download.origin === "spark" ? "Spark Store" : "APM Store"
}}</span> }}</span>
</div> </div>
<div v-if="download.startTime" class="flex justify-between py-1"> <div
<span class="text-slate-400">开始时间</span> v-if="download.startTime"
<span>{{ formatDate(download.startTime) }}</span> class="flex justify-between gap-2"
>
<span class="shrink-0 text-slate-400">开始时间</span>
<span class="truncate">{{ formatDate(download.startTime) }}</span>
</div> </div>
<div v-if="download.endTime" class="flex justify-between py-1"> <div
<span class="text-slate-400">完成时间</span> v-if="download.endTime"
<span>{{ formatDate(download.endTime) }}</span> class="flex justify-between gap-2"
>
<span class="shrink-0 text-slate-400">完成时间</span>
<span class="truncate">{{ formatDate(download.endTime) }}</span>
</div> </div>
<div <div
v-if="download.error" v-if="download.error"
class="flex justify-between py-1 text-rose-500" class="col-span-2 flex justify-between gap-2 text-rose-500"
> >
<span>错误信息</span> <span class="shrink-0">错误信息</span>
<span class="text-right">{{ download.error }}</span> <span class="truncate text-right">{{ download.error }}</span>
</div> </div>
</div> </div>
<div <div
v-if="download.logs && download.logs.length" v-if="download.logs && download.logs.length"
class="rounded-2xl border border-slate-200/60 p-4 dark:border-slate-800/60" class="rounded-2xl border border-slate-200/60 p-3 dark:border-slate-800/60"
> >
<div class="mb-3 flex items-center justify-between"> <div class="mb-2 flex items-center justify-between">
<span class="font-semibold text-slate-800 dark:text-slate-100" <span class="font-semibold text-slate-800 dark:text-slate-100"
>下载日志</span >下载日志</span
> >
@@ -155,22 +164,22 @@
</button> </button>
</div> </div>
<div <div
class="max-h-48 space-y-2 overflow-y-auto overscroll-contain rounded-2xl bg-slate-50/80 p-3 font-mono text-xs text-slate-600 dark:bg-slate-900/60 dark:text-slate-300" class="max-h-32 space-y-1.5 overflow-y-auto overscroll-contain rounded-2xl bg-slate-50/80 p-3 font-mono text-xs text-slate-600 dark:bg-slate-900/60 dark:text-slate-300"
> >
<div <div
v-for="(log, index) in download.logs" v-for="(log, index) in download.logs"
:key="index" :key="index"
class="flex gap-3" class="flex gap-3"
> >
<span class="text-slate-400">{{ <span class="shrink-0 text-slate-400">{{
formatLogTime(log.time) formatLogTime(log.time)
}}</span> }}</span>
<span>{{ log.message }}</span> <span class="break-all">{{ log.message }}</span>
</div> </div>
</div> </div>
</div> </div>
<div class="flex flex-wrap justify-end gap-3"> <div class="flex flex-wrap justify-end gap-3 pt-1">
<button <button
v-if=" v-if="
download.status === 'downloading' || download.status === 'downloading' ||
+24 -40
View File
@@ -1,12 +1,19 @@
<template> <template>
<!-- 空队列仅显示右下角小按钮 -->
<button
v-if="downloads.length === 0"
type="button"
class="fixed bottom-4 right-4 z-40 inline-flex h-10 w-10 items-center justify-center rounded-full border border-slate-200/70 bg-white text-slate-500 shadow-lg transition hover:-translate-y-0.5 hover:text-brand dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400 dark:hover:text-white sm:bottom-6 sm:right-6"
title="暂无下载任务"
aria-label="下载队列(空)"
>
<i class="fas fa-download"></i>
</button>
<!-- 有任务展开为完整面板默认展开可点 chevron 折叠列表 -->
<div <div
ref="queueRef" v-else
class="fixed inset-x-4 bottom-4 z-40 rounded-3xl border border-slate-200/70 bg-white shadow-2xl transition-all duration-200 dark:border-slate-800/70 dark:bg-slate-900 sm:left-auto sm:right-6 sm:w-96" class="fixed inset-x-4 bottom-4 z-40 rounded-3xl border border-slate-200/70 bg-white shadow-2xl transition-all duration-200 dark:border-slate-800/70 dark:bg-slate-900 sm:left-auto sm:right-6 sm:w-96"
:class="
isHidden
? 'pointer-events-none translate-y-[calc(100%+1rem)] opacity-0'
: 'translate-y-0 opacity-100'
"
> >
<div <div
class="flex items-center justify-between px-5 py-4" class="flex items-center justify-between px-5 py-4"
@@ -59,14 +66,7 @@
v-show="isExpanded" v-show="isExpanded"
class="max-h-96 overflow-y-auto overscroll-contain px-3 pb-4" class="max-h-96 overflow-y-auto overscroll-contain px-3 pb-4"
> >
<div <div class="space-y-2">
v-if="downloads.length === 0"
class="flex flex-col items-center justify-center rounded-2xl border border-dashed border-slate-200/80 px-4 py-12 text-slate-500 dark:border-slate-800/80 dark:text-slate-400"
>
<i class="fas fa-inbox text-3xl"></i>
<p class="mt-3 text-sm">暂无下载任务</p>
</div>
<div v-else class="space-y-2">
<div <div
v-for="download in downloads" v-for="download in downloads"
:key="download.id" :key="download.id"
@@ -136,7 +136,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from "vue"; import { computed, ref, watch } from "vue";
import type { DownloadItem } from "../global/typedefinition"; import type { DownloadItem } from "../global/typedefinition";
const props = defineProps<{ const props = defineProps<{
@@ -152,31 +152,7 @@ const emit = defineEmits<{
(e: "show-detail", download: DownloadItem): void; (e: "show-detail", download: DownloadItem): void;
}>(); }>();
const isExpanded = ref(false); const isExpanded = ref(true);
const isHidden = ref(false);
const queueRef = ref<HTMLElement | null>(null);
const onWheel = (event: WheelEvent) => {
if (queueRef.value?.contains(event.target as Node) || event.deltaY === 0) {
return;
}
if (event.deltaY > 0) {
isExpanded.value = false;
isHidden.value = true;
return;
}
isHidden.value = false;
};
onMounted(() => {
document.addEventListener("wheel", onWheel, { passive: true, capture: true });
});
onUnmounted(() => {
document.removeEventListener("wheel", onWheel, { capture: true });
});
const completedDownloads = computed(() => { const completedDownloads = computed(() => {
return props.downloads.filter((d) => d.status === "completed").length; return props.downloads.filter((d) => d.status === "completed").length;
@@ -197,4 +173,12 @@ const clearCompleted = () => {
const showDownloadDetail = (download: DownloadItem) => { const showDownloadDetail = (download: DownloadItem) => {
emit("show-detail", download); emit("show-detail", download);
}; };
// ""
watch(
() => props.downloads.length > 0,
(hasTasks) => {
if (hasTasks) isExpanded.value = true;
},
);
</script> </script>
+96 -72
View File
@@ -1,12 +1,18 @@
<template> <template>
<div class="space-y-6"> <!--
首页视图仅渲染"区域1 精选(links"
历史版本曾渲染"区域2 板块(homelist.json"作为第二个 section
若回滚/合并导致该 section 复活板块应用会与侧边栏入口"装机必备/社区精品/APM 扩展"内容重复展示双来源同数据
现行设计板块已迁移至侧边栏入口App.vue `loadHomeListEntries`首页不再渲染板块参见 commit 438b9baa
-->
<div class="flex h-full min-h-0 flex-col gap-2 overflow-hidden">
<!-- 初始加载状态 - 只有在完全没有数据时显示 --> <!-- 初始加载状态 - 只有在完全没有数据时显示 -->
<div <div
v-if="loading && links.length === 0" v-if="loading && links.length === 0"
class="flex flex-col items-center justify-center py-12 text-slate-500 dark:text-slate-400" class="flex flex-1 flex-col items-center justify-center text-slate-500 dark:text-slate-400"
> >
<i class="fas fa-spinner fa-spin text-2xl mb-3"></i> <i class="fas fa-spinner fa-spin text-2xl mb-3"></i>
<span class="text-sm">正在加载首页内容</span> <span class="text-base">正在加载首页内容</span>
</div> </div>
<div <div
v-else-if="error" v-else-if="error"
@@ -17,7 +23,7 @@
<!-- 无数据时显示欢迎信息 --> <!-- 无数据时显示欢迎信息 -->
<div <div
v-else-if="links.length === 0" v-else-if="links.length === 0"
class="flex flex-col items-center justify-center py-20 text-center" class="flex flex-1 flex-col items-center justify-center text-center"
> >
<img <img
v-if="storeFilter === 'apm'" v-if="storeFilter === 'apm'"
@@ -38,7 +44,7 @@
: "欢迎来到星火应用商店" : "欢迎来到星火应用商店"
}} }}
</h1> </h1>
<p class="text-sm text-slate-500 dark:text-slate-400"> <p class="text-base text-slate-500 dark:text-slate-400">
{{ {{
storeFilter === "apm" storeFilter === "apm"
? "探索丰富的应用,发现更多精彩内容" ? "探索丰富的应用,发现更多精彩内容"
@@ -47,83 +53,100 @@
</p> </p>
</div> </div>
<!-- 有数据就立即展示图片逐步加载 --> <!-- 有数据就立即展示图片逐步加载 -->
<div v-else> <template v-else>
<!-- 左上角欢迎语 --> <!-- ============ 区域1 · 精选两行网格 4×2静态不滚动 ============ -->
<div class="mb-4"> <section class="shrink-0">
<h1 class="text-xl font-bold text-slate-800 dark:text-slate-200"> <div class="mb-4">
{{ <h1 class="text-2xl font-bold text-slate-800 dark:text-slate-200">
storeFilter === "apm" {{
? "欢迎来到星火应用商店 (Amber PM)" storeFilter === "apm" ? "星火应用商店 (Amber PM)" : "星火应用商店"
: "欢迎来到星火应用商店" }}
}} </h1>
</h1> <p class="text-base text-slate-500 dark:text-slate-400 mt-0.5">
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1"> 探索丰富的应用发现更多精彩内容
探索丰富的应用发现更多精彩内容 </p>
</p> </div>
</div> <div
v-if="links.length > 0"
<!-- Links 区域 --> class="grid grid-cols-2 gap-4 sm:grid-cols-4"
<div v-if="links.length > 0" class="grid gap-5 auto-fit-grid">
<a
v-for="link in links"
:key="link.url + link.name"
:href="link.type === '_blank' ? undefined : link.url"
@click.prevent="onLinkClick(link)"
class="group block overflow-hidden rounded-xl transition-transform duration-300 hover:scale-[1.02]"
:title="link.more as string"
> >
<div <a
class="relative w-full aspect-[850/400] overflow-hidden rounded-xl bg-slate-100 dark:bg-slate-800" v-for="link in links"
:key="link.url + link.name"
:href="link.type === '_blank' ? undefined : link.url"
@click.prevent="onLinkClick(link)"
class="group block overflow-hidden rounded-lg transition-transform duration-300 hover:scale-[1.02]"
:title="link.more as string"
> >
<img
:src="computedImgUrl(link)"
class="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
loading="lazy"
@load="onImageLoad(link.url + link.name)"
@error="onImageError(link.url + link.name)"
:class="{
'opacity-0': !imageLoaded[link.url + link.name],
'opacity-100 transition-opacity duration-300':
imageLoaded[link.url + link.name],
}"
/>
<div <div
v-if="!imageLoaded[link.url + link.name]" class="relative w-full overflow-hidden rounded-lg bg-slate-100 dark:bg-slate-800"
class="absolute inset-0 flex items-center justify-center"
> >
<img
:src="computedImgUrl(link)"
class="block w-full transition-transform duration-500 group-hover:scale-105"
loading="lazy"
decoding="async"
@load="onImageLoad(link.url + link.name)"
@error="onImageError(link.url + link.name)"
:class="{
'aspect-[850/260] object-cover':
!imageLoaded[link.url + link.name],
'h-auto': imageLoaded[link.url + link.name],
'opacity-0': !imageLoaded[link.url + link.name],
'opacity-100 transition-opacity duration-300':
imageLoaded[link.url + link.name],
}"
/>
<div <div
class="h-10 w-10 animate-pulse rounded-full bg-slate-200 dark:bg-slate-700" v-if="!imageLoaded[link.url + link.name]"
></div> class="absolute inset-0 flex items-center justify-center"
>
<div
class="h-10 w-10 animate-pulse rounded-full bg-slate-200 dark:bg-slate-700"
></div>
</div>
</div> </div>
</div> <div class="mt-1.5 px-1">
<div class="mt-3 px-1"> <div
<div class="text-sm font-semibold text-slate-900 dark:text-white group-hover:text-brand dark:group-hover:text-brand transition-colors"
class="text-base font-semibold text-slate-900 dark:text-white group-hover:text-brand dark:group-hover:text-brand transition-colors" >
> {{ link.name }}
{{ link.name }} </div>
<div
class="text-sm text-slate-500 dark:text-slate-400 mt-0.5 line-clamp-1"
>
{{ link.more }}
</div>
</div> </div>
<div </a>
class="text-sm text-slate-500 dark:text-slate-400 mt-0.5 line-clamp-1" </div>
> <!-- 致谢说明F2.3 复用置于区域1 其他内容下方当前按需求隐藏代码保留 -->
{{ link.more }} <ThanksCard v-if="false" class="mt-4" />
</div> </section>
</div> </template>
</a>
</div>
</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { APM_STORE_BASE_URL } from "../global/storeConfig";
import { reactive } from "vue"; import { reactive } from "vue";
import type { HomeLink } from "../global/typedefinition"; import { APM_STORE_BASE_URL } from "../global/storeConfig";
import type { HomeLink, App } from "../global/typedefinition";
import ThanksCard from "./ThanksCard.vue";
defineProps<{ withDefaults(
links: HomeLink[]; defineProps<{
loading: boolean; links: HomeLink[];
error: string; loading: boolean;
storeFilter?: "spark" | "apm" | "both"; error: string;
storeFilter?: "spark" | "apm" | "both";
}>(),
{
storeFilter: "both",
},
);
const emit = defineEmits<{
(e: "open-detail", app: App): void;
}>(); }>();
// //
@@ -152,9 +175,10 @@ const onLinkClick = (link: HomeLink) => {
window.location.href = link.url; window.location.href = link.url;
} }
}; };
</script>
<style scoped></style> // >= 1 "x.x"
// (download count now formatted inside AppCard via its downloadCount prop)
</script>
<style scoped> <style scoped>
/* Link 卡片网格 - 固定最小宽度 180px */ /* Link 卡片网格 - 固定最小宽度 180px */
@@ -168,7 +192,7 @@ const onLinkClick = (link: HomeLink) => {
@media (max-width: 640px) { @media (max-width: 640px) {
.auto-fit-grid { .auto-fit-grid {
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 0.75rem; gap: 12px;
} }
} }
</style> </style>
+148
View File
@@ -0,0 +1,148 @@
<template>
<div class="flex h-full min-h-0 flex-col gap-3">
<!-- 两个榜单并排每个榜单内容在容器内滚动 -->
<div class="grid min-h-0 flex-1 gap-6 lg:grid-cols-2">
<!-- 贡献荣耀榜 -->
<section class="flex min-h-0 flex-col">
<h2
class="mb-2 shrink-0 text-sm font-bold text-slate-800 dark:text-slate-200"
>
贡献荣耀榜
</h2>
<div
class="grid grid-rows-1 min-h-0 flex-1 gap-4"
:class="gridCols"
>
<div v-if="showSpark" class="flex min-h-0 flex-col">
<SourceLabel origin="spark" />
<div
class="scrollbar-muted min-h-0 flex-1 space-y-1.5 overflow-y-auto pr-1"
>
<RankingContributorRow
v-for="(item, i) in contributeSpark"
:key="item.name"
:rank="i + 1"
:name="item.name"
:count="item.count"
label="上榜应用"
/>
<EmptyState
v-if="contributeSpark.length === 0"
:loading="appsLoading"
/>
</div>
</div>
<div v-if="showApm" class="flex min-h-0 flex-col">
<SourceLabel origin="apm" />
<div
class="scrollbar-muted min-h-0 flex-1 space-y-1.5 overflow-y-auto pr-1"
>
<RankingContributorRow
v-for="(item, i) in contributeApm"
:key="item.name"
:rank="i + 1"
:name="item.name"
:count="item.count"
label="上榜应用"
/>
<EmptyState
v-if="contributeApm.length === 0"
:loading="appsLoading"
/>
</div>
</div>
</div>
</section>
<!-- 更新荣耀榜 -->
<section class="flex min-h-0 flex-col">
<h2
class="mb-2 shrink-0 text-sm font-bold text-slate-800 dark:text-slate-200"
>
更新荣耀榜
</h2>
<div
class="grid grid-rows-1 min-h-0 flex-1 gap-4"
:class="gridCols"
>
<div v-if="showSpark" class="flex min-h-0 flex-col">
<SourceLabel origin="spark" />
<div
class="scrollbar-muted min-h-0 flex-1 space-y-1.5 overflow-y-auto pr-1"
>
<RankingContributorRow
v-for="(item, i) in updateContributeSpark"
:key="item.name"
:rank="i + 1"
:name="item.name"
:count="item.count"
label="近期更新"
/>
<EmptyState
v-if="updateContributeSpark.length === 0"
:loading="appsLoading"
/>
</div>
</div>
<div v-if="showApm" class="flex min-h-0 flex-col">
<SourceLabel origin="apm" />
<div
class="scrollbar-muted min-h-0 flex-1 space-y-1.5 overflow-y-auto pr-1"
>
<RankingContributorRow
v-for="(item, i) in updateContributeApm"
:key="item.name"
:rank="i + 1"
:name="item.name"
:count="item.count"
label="近期更新"
/>
<EmptyState
v-if="updateContributeApm.length === 0"
:loading="appsLoading"
/>
</div>
</div>
</div>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import type { App } from "../global/typedefinition";
import { aggregateContributors, topUpdatedContributors } from "../modules/ranking";
import SourceLabel from "./SourceLabel.vue";
import RankingContributorRow from "./RankingContributorRow.vue";
import EmptyState from "./RankingEmptyState.vue";
const props = defineProps<{
apps: App[];
storeFilter?: "spark" | "apm" | "both";
}>();
const TOP = 10;
const showSpark = computed(() => props.storeFilter !== "apm");
const showApm = computed(() => props.storeFilter !== "spark");
const gridCols = computed(() =>
showSpark.value && showApm.value
? "grid-cols-1 sm:grid-cols-2"
: "grid-cols-1",
);
const appsLoading = computed(() => props.apps.length === 0);
const contributeSpark = computed(() =>
aggregateContributors(props.apps, "spark", TOP),
);
const contributeApm = computed(() =>
aggregateContributors(props.apps, "apm", TOP),
);
const updateContributeSpark = computed(() =>
topUpdatedContributors(props.apps, "spark"),
);
const updateContributeApm = computed(() =>
topUpdatedContributors(props.apps, "apm"),
);
</script>
+359 -82
View File
@@ -14,71 +14,142 @@
@wheel="onOverlayWheel" @wheel="onOverlayWheel"
> >
<div <div
class="flex w-full max-w-4xl max-h-[85vh] flex-col rounded-3xl border border-white/10 bg-white/95 shadow-2xl dark:border-slate-800 dark:bg-slate-900" class="flex w-full max-w-4xl max-h-[85vh] flex-col overflow-hidden rounded-3xl border border-white/10 bg-white/95 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
> >
<div <div
class="flex items-start justify-between border-b border-slate-200/70 p-6 dark:border-slate-800/70" class="flex items-start justify-between border-b border-slate-200/70 p-6 dark:border-slate-800/70"
> >
<div> <div class="flex flex-col gap-2">
<p class="text-2xl font-semibold text-slate-900 dark:text-white"> <p class="text-2xl font-semibold text-slate-900 dark:text-white">
已安装应用 已安装应用
</p> </p>
<p class="text-sm text-slate-500 dark:text-slate-400"> <p class="text-sm text-slate-500 dark:text-slate-400">
管理本机安装的应用程序 管理本机安装的应用程序
</p> </p>
<div
v-if="!loading && !error"
class="mt-2 flex flex-wrap items-center gap-3"
>
<div
class="inline-flex flex-wrap items-stretch overflow-hidden rounded-2xl border border-slate-200/70 bg-slate-50/60 text-sm shadow-sm dark:border-slate-700/60 dark:bg-slate-800/40"
>
<!-- APM -->
<div
class="group flex cursor-pointer items-center gap-2.5 px-3.5 py-2 transition hover:bg-amber-50/80 dark:hover:bg-amber-500/10"
:class="
filterOrigin === 'apm'
? 'bg-amber-100 ring-2 ring-amber-400/50 dark:bg-amber-500/20'
: ''
"
title="仅显示 APM 应用"
role="button"
@click="filterOrigin = 'apm'"
>
<span
class="flex h-7 w-7 items-center justify-center rounded-lg bg-gradient-to-br from-amber-400 to-orange-500 text-white shadow-sm shadow-amber-500/30"
>
<i class="fas fa-box-open text-[12px]"></i>
</span>
<div class="flex flex-col leading-tight">
<span
class="text-[15px] font-bold tabular-nums text-amber-700 dark:text-amber-300"
>{{ apmCount }}</span
>
<span
class="text-[10px] font-medium uppercase tracking-wider text-amber-600/80 dark:text-amber-400/80"
>APM</span
>
</div>
</div>
<!-- 分隔线 -->
<span
class="self-stretch w-px bg-slate-200/80 dark:bg-slate-700/80"
></span>
<!-- Spark -->
<div
class="group flex cursor-pointer items-center gap-2.5 px-3.5 py-2 transition hover:bg-sky-50/80 dark:hover:bg-sky-500/10"
:class="
filterOrigin === 'spark'
? 'bg-sky-100 ring-2 ring-sky-400/50 dark:bg-sky-500/20'
: ''
"
title="仅显示 Spark 应用"
role="button"
@click="filterOrigin = 'spark'"
>
<span
class="flex h-7 w-7 items-center justify-center rounded-lg bg-gradient-to-br from-sky-400 to-blue-500 text-white shadow-sm shadow-sky-500/30"
>
<i class="fas fa-bolt text-[12px]"></i>
</span>
<div class="flex flex-col leading-tight">
<span
class="text-[15px] font-bold tabular-nums text-sky-700 dark:text-sky-300"
>{{ sparkCount }}</span
>
<span
class="text-[10px] font-medium uppercase tracking-wider text-sky-600/80 dark:text-sky-400/80"
>Spark</span
>
</div>
</div>
<!-- 分隔线 -->
<span
class="self-stretch w-px bg-slate-200/80 dark:bg-slate-700/80"
></span>
<!-- 总数 -->
<div
class="flex cursor-pointer items-center gap-2.5 px-3.5 py-2"
:class="
filterOrigin === 'all'
? 'bg-slate-100 ring-2 ring-slate-400/40 dark:bg-slate-700/40'
: ''
"
title="显示全部已安装应用"
role="button"
@click="filterOrigin = 'all'"
>
<span
class="flex h-7 w-7 items-center justify-center rounded-lg bg-gradient-to-br from-slate-500 to-slate-700 text-white shadow-sm dark:from-slate-400 dark:to-slate-600"
>
<i class="fas fa-cubes text-[12px]"></i>
</span>
<div class="flex flex-col leading-tight">
<span
class="text-[15px] font-bold tabular-nums text-slate-900 dark:text-white"
>{{ totalCount }}</span
>
<span
class="text-[10px] font-medium uppercase tracking-wider text-slate-500 dark:text-slate-400"
>总数</span
>
</div>
</div>
</div>
<!-- 搜索框 -->
<div class="relative flex-1 min-w-[200px]">
<i
class="fas fa-search pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-slate-400"
></i>
<input
v-model="searchQuery"
type="text"
placeholder="搜索已安装应用…"
class="w-full rounded-2xl border border-slate-200/70 bg-slate-50/60 py-2 pl-9 pr-9 text-sm text-slate-700 placeholder-slate-400 transition focus:border-brand/60 focus:bg-white focus:outline-none focus:ring-2 focus:ring-brand/20 dark:border-slate-700 dark:bg-slate-800/40 dark:text-slate-200 dark:placeholder-slate-500 dark:focus:bg-slate-800"
/>
<button
v-if="searchQuery"
type="button"
class="absolute right-2 top-1/2 inline-flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-full text-slate-400 transition hover:bg-slate-200/60 hover:text-slate-700 dark:hover:bg-slate-700 dark:hover:text-white"
aria-label="清除搜索"
@click="searchQuery = ''"
>
<i class="fas fa-xmark text-xs"></i>
</button>
</div>
</div>
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<!-- 云端同步功能暂时关闭
<button
type="button"
class="inline-flex items-center gap-2 rounded-2xl border border-brand/30 px-4 py-2 text-sm font-semibold text-brand transition hover:bg-brand/10 disabled:opacity-40"
:disabled="syncing"
@click="handleSyncClick"
>
<i class="fas fa-cloud-arrow-up"></i>
{{ syncing ? "同步中" : "同步到账号" }}
</button>
<button
type="button"
class="inline-flex items-center gap-2 rounded-2xl border border-slate-200/70 px-4 py-2 text-sm font-semibold text-slate-600 transition hover:bg-slate-50 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
@click="handleRestoreClick"
>
<i class="fas fa-cloud-arrow-down"></i>
从账号恢复
</button>
-->
<div
v-if="showOriginSwitcher"
class="flex items-center rounded-2xl border border-slate-200/70 p-1 dark:border-slate-800/70"
>
<button
v-if="apmEnabled"
type="button"
class="rounded-xl px-4 py-1.5 text-sm font-semibold transition"
:class="
activeOrigin === 'apm'
? 'bg-brand/10 text-brand dark:bg-brand/15'
: 'text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200'
"
:disabled="!apmAvailable"
@click="$emit('switch-origin', 'apm')"
>
APM 软件
</button>
<button
v-if="sparkEnabled"
type="button"
class="rounded-xl px-4 py-1.5 text-sm font-semibold transition"
:class="
activeOrigin === 'spark'
? 'bg-brand/10 text-brand dark:bg-brand/15'
: 'text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200'
"
@click="$emit('switch-origin', 'spark')"
>
Spark 软件
</button>
</div>
<button <button
type="button" type="button"
class="inline-flex items-center gap-2 rounded-2xl border border-slate-200/70 px-4 py-2 text-sm font-semibold text-slate-600 transition hover:bg-slate-50 disabled:opacity-40 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800" class="inline-flex items-center gap-2 rounded-2xl border border-slate-200/70 px-4 py-2 text-sm font-semibold text-slate-600 transition hover:bg-slate-50 disabled:opacity-40 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
@@ -100,7 +171,7 @@
</div> </div>
<div <div
class="flex-1 overflow-y-auto overscroll-contain scrollbar-nowidth scrollbar-thumb-slate-200 dark:scrollbar-thumb-slate-700 p-6 space-y-4" class="flex-1 overflow-y-auto overscroll-contain p-6 space-y-4 mb-6"
> >
<div <div
v-if="syncMessage" v-if="syncMessage"
@@ -120,15 +191,40 @@
> >
{{ error }} {{ error }}
</div> </div>
<div
v-else-if="warning"
class="rounded-2xl border border-amber-200/70 bg-amber-50/60 px-4 py-3 text-sm text-amber-700 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-300"
>
<i class="fas fa-triangle-exclamation mr-1.5"></i>
{{ warning }}
</div>
<div <div
v-else-if="apps.length === 0" v-else-if="apps.length === 0"
class="rounded-2xl border border-slate-200/70 px-4 py-10 text-center text-slate-500 dark:border-slate-800/70 dark:text-slate-400" class="rounded-2xl border border-slate-200/70 px-4 py-10 text-center text-slate-500 dark:border-slate-800/70 dark:text-slate-400"
> >
暂无已安装应用 暂无已安装应用
</div> </div>
<div
v-else-if="filteredApps.length === 0"
class="rounded-2xl border border-slate-200/70 px-4 py-10 text-center text-slate-500 dark:border-slate-800/70 dark:text-slate-400"
>
<i class="fas fa-search mr-1.5 text-slate-400"></i>
<template v-if="searchQuery">
未找到匹配<span
class="font-semibold text-slate-700 dark:text-slate-300"
>{{ searchQuery }}</span
>的已安装应用
</template>
<template v-else-if="filterOrigin === 'apm'">
暂无已安装的 APM 应用
</template>
<template v-else-if="filterOrigin === 'spark'">
暂无已安装的 Spark 应用
</template>
</div>
<div v-else class="space-y-3"> <div v-else class="space-y-3">
<div <div
v-for="app in apps" v-for="app in filteredApps"
:key="app.pkgname" :key="app.pkgname"
class="flex flex-col gap-3 rounded-2xl border border-slate-200/70 bg-white/90 p-4 shadow-sm dark:border-slate-800/70 dark:bg-slate-900/70 sm:flex-row sm:items-center sm:justify-between" class="flex flex-col gap-3 rounded-2xl border border-slate-200/70 bg-white/90 p-4 shadow-sm dark:border-slate-800/70 dark:bg-slate-900/70 sm:flex-row sm:items-center sm:justify-between"
> >
@@ -155,6 +251,24 @@
> >
{{ app.name }} {{ app.name }}
</p> </p>
<span
v-for="o in app.origins && app.origins.length
? app.origins
: [app.origin ?? 'spark']"
:key="o"
:data-testid="
o === 'apm' ? 'origin-tag-apm' : 'origin-tag-spark'
"
class="rounded-md px-2 py-0.5 text-[11px] font-semibold"
:class="
o === 'apm'
? 'bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400'
: 'bg-sky-100 text-sky-700 dark:bg-sky-500/20 dark:text-sky-400'
"
:title="o === 'apm' ? 'APM 软件' : 'Spark 软件'"
>
{{ o === "apm" ? "APM" : "Spark" }}
</span>
<span <span
v-if="app.isDependency" v-if="app.isDependency"
class="rounded-md bg-rose-100 px-2 py-0.5 text-[11px] font-semibold text-rose-600 dark:bg-rose-500/20 dark:text-rose-400" class="rounded-md bg-rose-100 px-2 py-0.5 text-[11px] font-semibold text-rose-600 dark:bg-rose-500/20 dark:text-rose-400"
@@ -179,7 +293,7 @@
<button <button
type="button" type="button"
class="inline-flex items-center gap-2 rounded-2xl border border-slate-300/70 px-4 py-2 text-sm font-semibold text-slate-700 transition hover:bg-slate-50 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800" class="inline-flex items-center gap-2 rounded-2xl border border-slate-300/70 px-4 py-2 text-sm font-semibold text-slate-700 transition hover:bg-slate-50 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
@click="$emit('open-app', app)" @click="onOpenClick(app)"
> >
<i class="fas fa-play"></i> <i class="fas fa-play"></i>
打开 打开
@@ -207,32 +321,122 @@
</div> </div>
</div> </div>
</div> </div>
<!-- 双来源安装打开时选择 APM 还是 Spark -->
<Transition
enter-active-class="duration-200 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="duration-150 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-if="openChoiceApp"
class="fixed inset-0 z-[85] flex items-center justify-center bg-slate-900/70 p-4"
@click.self="openChoiceApp = null"
>
<div
class="relative w-full max-w-sm overflow-hidden rounded-3xl border border-white/10 bg-white/95 p-6 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
>
<div class="mb-5 flex items-start gap-3">
<div
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-gradient-to-br from-sky-100 to-indigo-50 shadow-inner dark:from-sky-900/30 dark:to-indigo-800/20"
>
<i class="fas fa-external-link-alt text-xl text-sky-500"></i>
</div>
<div>
<h3 class="text-lg font-bold text-slate-900 dark:text-white">
打开应用
</h3>
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">
该应用同时通过 APM Spark 安装请选择要打开的来源
</p>
</div>
</div>
<div class="flex gap-3">
<button
type="button"
class="inline-flex flex-1 items-center justify-center gap-2 rounded-xl border border-amber-300/70 px-4 py-2.5 text-sm font-semibold text-amber-700 transition hover:bg-amber-50 dark:border-amber-500/40 dark:text-amber-300 dark:hover:bg-amber-500/10"
@click="confirmOpen('apm')"
>
<i class="fas fa-box-open"></i>
打开 APM
</button>
<button
type="button"
class="inline-flex flex-1 items-center justify-center gap-2 rounded-xl border border-sky-300/70 px-4 py-2.5 text-sm font-semibold text-sky-700 transition hover:bg-sky-50 dark:border-sky-500/40 dark:text-sky-300 dark:hover:bg-sky-500/10"
@click="confirmOpen('spark')"
>
<i class="fas fa-bolt"></i>
打开 Spark
</button>
</div>
<div class="mt-4 flex justify-end">
<button
type="button"
class="rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
@click="openChoiceApp = null"
>
取消
</button>
</div>
</div>
</div>
</Transition>
</div> </div>
</Transition> </Transition>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, reactive } from "vue"; import { computed, reactive, ref } from "vue";
import { App } from "../global/typedefinition"; import { App } from "../global/typedefinition";
import { APM_STORE_BASE_URL } from "../global/storeConfig"; import { APM_STORE_BASE_URL } from "../global/storeConfig";
const iconErrors = reactive<Record<string, boolean>>({}); const iconErrors = reactive<Record<string, boolean>>({});
// app.icons
//
const ALLOWED_LOCAL_ICON_PREFIXES = [
"/usr/share/icons/",
"/usr/share/pixmaps/",
"/usr/lib/",
"/usr/local/share/icons/",
"/usr/local/share/pixmaps/",
"/opt/",
"/var/lib/apm/",
"/var/lib/dpkg/info/",
];
const getIconUrl = (app: App) => { const getIconUrl = (app: App) => {
if (app.icons && app.icons.startsWith("/")) return `file://${app.icons}`; // ("..")
if (
app.icons &&
app.icons.startsWith("/") &&
!app.icons.includes("..") &&
ALLOWED_LOCAL_ICON_PREFIXES.some((prefix) => app.icons!.startsWith(prefix))
) {
return `file://${app.icons}`;
}
if (!app.category || app.category === "unknown") return ""; if (!app.category || app.category === "unknown") return "";
const arch = window.apm_store.arch || "amd64"; const arch = window.apm_store.arch || "amd64";
const finalArch = app.origin === "spark" ? `${arch}-store` : `${arch}-apm`; const finalArch = app.origin === "spark" ? `${arch}-store` : `${arch}-apm`;
return `${APM_STORE_BASE_URL}/${finalArch}/${app.category}/${app.pkgname}/icon.png`; return `${APM_STORE_BASE_URL}/${finalArch}/${app.category}/${app.pkgname}/icon.png`;
}; };
const canOpenDetail = (app: App) => { // ""
// - unknown
// - (more) / (website) / (author)
// - (img_urls)
// /
const canOpenDetail = (app: App): boolean => {
const hasCategory = app.category !== "unknown";
const hasDescription = Boolean(app.more);
const hasWebsite = Boolean(app.website);
const hasAuthor = Boolean(app.author);
const hasScreenshots = (app.img_urls?.length ?? 0) > 0;
return ( return (
app.category !== "unknown" || hasCategory || hasDescription || hasWebsite || hasAuthor || hasScreenshots
Boolean(app.more) ||
Boolean(app.website) ||
Boolean(app.author) ||
(app.img_urls?.length ?? 0) > 0
); );
}; };
@@ -241,21 +445,84 @@ const props = defineProps<{
apps: App[]; apps: App[];
loading: boolean; loading: boolean;
error: string; error: string;
activeOrigin: "apm" | "spark"; warning: string;
storeFilter: "spark" | "apm" | "both";
sparkAvailable: boolean;
apmAvailable: boolean;
loggedIn: boolean; loggedIn: boolean;
syncing: boolean; syncing: boolean;
syncMessage: string; syncMessage: string;
}>(); }>();
// eslint-disable-next-line @typescript-eslint/no-unused-vars // origins 退 origin
const hasOrigin = (app: App, origin: "spark" | "apm"): boolean =>
app.origins?.includes(origin) ?? app.origin === origin;
// APM
const ORIGIN_PRIORITY: Record<"spark" | "apm", number> = { apm: 0, spark: 1 };
const originPriority = (app: App): number =>
hasOrigin(app, "apm") ? ORIGIN_PRIORITY.apm : ORIGIN_PRIORITY.spark;
// APM / Spark pkgname
//
//
const searchFilteredApps = computed(() => {
const q = searchQuery.value.trim().toLowerCase();
if (!q) return props.apps;
return props.apps.filter(
(a) =>
a.name.toLowerCase().includes(q) || a.pkgname.toLowerCase().includes(q),
);
});
// APM / Spark
// pkgname props.apps filter
const appStats = computed(() => {
let apm = 0;
let spark = 0;
for (const a of searchFilteredApps.value) {
if (hasOrigin(a, "apm")) apm++;
if (hasOrigin(a, "spark")) spark++;
}
return { apm, spark };
});
const apmCount = computed(() => appStats.value.apm);
const sparkCount = computed(() => appStats.value.spark);
// = APM + Spark
const totalCount = computed(() => apmCount.value + sparkCount.value);
// all/apm/spark
const filterOrigin = ref<"all" | "apm" | "spark">("all");
// /
const searchQuery = ref("");
const filteredApps = computed(() => {
// + .filter()
// hasOrigin origins 退 a.origin
const q = searchQuery.value.trim().toLowerCase();
const originFilter = filterOrigin.value;
const matched = props.apps.filter((a) => {
if (originFilter === "apm" && !hasOrigin(a, "apm")) return false;
if (originFilter === "spark" && !hasOrigin(a, "spark")) return false;
if (q) {
const nameLower = a.name.toLowerCase();
const pkgLower = a.pkgname.toLowerCase();
if (!nameLower.includes(q) && !pkgLower.includes(q)) return false;
}
return true;
});
// APM
return [...matched].sort((a, b) => {
const pa = originPriority(a);
const pb = originPriority(b);
if (pa !== pb) return pa - pb;
//
return a.pkgname.localeCompare(b.pkgname);
});
});
const emit = defineEmits<{ const emit = defineEmits<{
(e: "close"): void; (e: "close"): void;
(e: "refresh"): void; (e: "refresh"): void;
(e: "uninstall", app: App): void; (e: "uninstall", app: App): void;
(e: "switch-origin", origin: "apm" | "spark"): void;
(e: "open-app", app: App): void; (e: "open-app", app: App): void;
(e: "open-detail", app: App): void; (e: "open-detail", app: App): void;
(e: "sync-to-account"): void; (e: "sync-to-account"): void;
@@ -263,6 +530,28 @@ const emit = defineEmits<{
(e: "request-login"): void; (e: "request-login"): void;
}>(); }>();
// "" APM Spark
const openChoiceApp = ref<App | null>(null);
const openOrigins = (app: App): Array<"spark" | "apm"> =>
app.origins && app.origins.length > 0 ? app.origins : [app.origin ?? "spark"];
const onOpenClick = (app: App) => {
const origins = openOrigins(app);
if (origins.length > 1) {
openChoiceApp.value = app;
} else {
// origin launch-app
emit("open-app", { ...app, origin: origins[0] });
}
};
const confirmOpen = (origin: "spark" | "apm") => {
if (openChoiceApp.value) {
emit("open-app", { ...openChoiceApp.value, origin });
}
openChoiceApp.value = null;
};
// //
// const handleSyncClick = () => { // const handleSyncClick = () => {
// if (props.loggedIn) { // if (props.loggedIn) {
@@ -287,16 +576,4 @@ const onOverlayWheel = (e: WheelEvent) => {
if (target.closest(".overflow-y-auto, .overflow-auto")) return; if (target.closest(".overflow-y-auto, .overflow-auto")) return;
e.preventDefault(); e.preventDefault();
}; };
const sparkEnabled = computed(() => {
return props.storeFilter !== "apm" && props.sparkAvailable;
});
const apmEnabled = computed(() => {
return props.storeFilter !== "spark" && props.apmAvailable;
});
const showOriginSwitcher = computed(() => {
return sparkEnabled.value && apmEnabled.value;
});
</script> </script>
+102
View File
@@ -0,0 +1,102 @@
<template>
<div
class="group flex cursor-pointer items-center gap-2 rounded-lg border border-slate-200/70 bg-white/90 p-2 transition hover:border-brand/50 dark:border-slate-800/60 dark:bg-slate-900/60"
@click="openDetail"
>
<!-- 排名序号 3 名金// -->
<span
class="w-5 shrink-0 text-center text-sm font-bold"
:class="rankClass"
>{{ rank }}</span
>
<!-- 应用图标 -->
<div
class="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-md bg-gradient-to-b from-slate-100 to-slate-200 shadow-inner dark:from-slate-800 dark:to-slate-700"
>
<img
v-if="!iconFailed"
:src="iconPath"
:alt="app.name"
class="h-full w-full object-cover"
@error="iconFailed = true"
/>
<i v-else class="fas fa-cube text-slate-400"></i>
</div>
<!-- 应用信息 -->
<div class="flex min-w-0 flex-1 flex-col gap-1 overflow-hidden">
<div class="flex items-center gap-2">
<div
class="truncate text-xs font-semibold text-slate-900 dark:text-white"
>
{{ app.name || "" }}
</div>
<span
v-if="app.origin === 'spark'"
class="rounded-md bg-orange-100 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-orange-600 dark:bg-orange-900/30 dark:text-orange-400"
>Spark</span
>
<span
v-else
class="rounded-md bg-blue-100 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-blue-600 dark:bg-blue-900/30 dark:text-blue-400"
>APM</span
>
<span
v-if="showDownload && downloadCount"
class="ml-auto shrink-0 text-[10px] text-slate-400 dark:text-slate-500"
>{{ formatDownloads(downloadCount) }}</span
>
</div>
<div
class="truncate text-xs leading-tight text-slate-500 dark:text-slate-400"
>
{{ app.pkgname || "" }} · {{ app.version || ""
}}<template v-if="updateTime"> · {{ updateTime }}</template>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
import { APM_STORE_BASE_URL } from "../global/storeConfig";
import type { App } from "../global/typedefinition";
const props = defineProps<{
app: App;
rank: number;
showDownload?: boolean;
downloadCount?: number;
updateTime?: string;
}>();
const emit = defineEmits<{ (e: "open-detail", app: App): void }>();
const iconFailed = ref(false);
const iconPath = computed(() => {
const arch = window.apm_store.arch || "amd64";
const finalArch =
props.app.origin === "spark" ? `${arch}-store` : `${arch}-apm`;
return `${APM_STORE_BASE_URL}/${finalArch}/${props.app.category}/${props.app.pkgname}/icon.png`;
});
const rankClass = computed(() => {
if (props.rank === 1) return "text-amber-400";
if (props.rank === 2) return "text-slate-400";
if (props.rank === 3) return "text-amber-600";
return "text-slate-400 dark:text-slate-500";
});
const formatDownloads = (n?: number): string => {
if (!n || n <= 0) return "0";
if (n >= 10000) {
const wan = n / 10000;
return `${wan.toFixed(1).replace(/\.0$/, "")}`;
}
return n.toLocaleString();
};
const openDetail = () => emit("open-detail", props.app);
</script>
+55
View File
@@ -0,0 +1,55 @@
<template>
<div
class="group flex items-center gap-2 rounded-lg border border-slate-200/70 bg-white/90 p-2 transition hover:border-brand/50 dark:border-slate-800/60 dark:bg-slate-900/60"
>
<!-- 排名序号 3 名金// -->
<span
class="w-5 shrink-0 text-center text-sm font-bold"
:class="rankClass"
>{{ rank }}</span
>
<!-- 贡献者头像 -->
<div
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-gradient-to-b from-amber-100 to-amber-200 text-amber-600 shadow-inner dark:from-amber-900/40 dark:to-amber-800/40"
>
<i class="fas fa-user"></i>
</div>
<!-- 贡献者信息 -->
<div class="flex min-w-0 flex-1 flex-col gap-1 overflow-hidden">
<div class="truncate text-xs font-semibold text-slate-900 dark:text-white">
{{ name }}
</div>
<div
class="truncate text-xs leading-tight text-slate-500 dark:text-slate-400"
>
{{ label }}
</div>
</div>
<!-- 数量徽标 -->
<span
class="shrink-0 rounded-full bg-slate-100 px-2 py-0.5 text-xs font-semibold text-slate-600 dark:bg-slate-800 dark:text-slate-300"
>{{ count }}</span
>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
const props = defineProps<{
rank: number;
name: string;
count: number;
label?: string;
}>();
const rankClass = computed(() => {
if (props.rank === 1) return "text-amber-400";
if (props.rank === 2) return "text-slate-400";
if (props.rank === 3) return "text-amber-600";
return "text-slate-400 dark:text-slate-500";
});
</script>
+18
View File
@@ -0,0 +1,18 @@
<template>
<div
class="flex items-center justify-center gap-2 py-4 text-xs text-slate-400"
>
<i v-if="loading" class="fas fa-spinner fa-spin"></i>
<span>{{ loading ? (loadingText || "加载中…") : "暂无数据" }}</span>
</div>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
loading?: boolean;
loadingText?: string;
}>(),
{ loading: false, loadingText: "加载中…" },
);
</script>
+145
View File
@@ -0,0 +1,145 @@
<template>
<div class="flex h-full min-h-0 flex-col gap-3">
<!-- 两个榜单并排每个榜单内容在容器内滚动 -->
<div class="grid min-h-0 flex-1 gap-6 lg:grid-cols-2">
<!-- 应用下载排行 -->
<section class="flex min-h-0 flex-col">
<h2
class="mb-2 shrink-0 text-sm font-bold text-slate-800 dark:text-slate-200"
>
应用下载排行
</h2>
<div
class="grid grid-rows-1 min-h-0 flex-1 gap-4"
:class="gridCols"
>
<div v-if="showSpark" class="flex min-h-0 flex-col">
<SourceLabel origin="spark" />
<div
class="scrollbar-muted min-h-0 flex-1 space-y-1.5 overflow-y-auto pr-1"
>
<RankingAppRow
v-for="(app, i) in downloadSpark"
:key="app.pkgname"
:app="app"
:rank="i + 1"
show-download
:download-count="app.downloadCount"
@open-detail="onOpen"
/>
<EmptyState
v-if="downloadSpark.length === 0"
:loading="rankingLoading"
loading-text="加载排行中…"
/>
</div>
</div>
<div v-if="showApm" class="flex min-h-0 flex-col">
<SourceLabel origin="apm" />
<div
class="scrollbar-muted min-h-0 flex-1 space-y-1.5 overflow-y-auto pr-1"
>
<RankingAppRow
v-for="(app, i) in downloadApm"
:key="app.pkgname"
:app="app"
:rank="i + 1"
show-download
:download-count="app.downloadCount"
@open-detail="onOpen"
/>
<EmptyState
v-if="downloadApm.length === 0"
:loading="rankingLoading"
loading-text="加载排行中…"
/>
</div>
</div>
</div>
</section>
<!-- 应用更新排行 -->
<section class="flex min-h-0 flex-col">
<h2
class="mb-2 shrink-0 text-sm font-bold text-slate-800 dark:text-slate-200"
>
应用更新排行
</h2>
<div
class="grid grid-rows-1 min-h-0 flex-1 gap-4"
:class="gridCols"
>
<div v-if="showSpark" class="flex min-h-0 flex-col">
<SourceLabel origin="spark" />
<div
class="scrollbar-muted min-h-0 flex-1 space-y-1.5 overflow-y-auto pr-1"
>
<RankingAppRow
v-for="(app, i) in updateSpark"
:key="app.pkgname"
:app="app"
:rank="i + 1"
:update-time="app.update"
@open-detail="onOpen"
/>
<EmptyState v-if="updateSpark.length === 0" :loading="appsLoading" />
</div>
</div>
<div v-if="showApm" class="flex min-h-0 flex-col">
<SourceLabel origin="apm" />
<div
class="scrollbar-muted min-h-0 flex-1 space-y-1.5 overflow-y-auto pr-1"
>
<RankingAppRow
v-for="(app, i) in updateApm"
:key="app.pkgname"
:app="app"
:rank="i + 1"
:update-time="app.update"
@open-detail="onOpen"
/>
<EmptyState v-if="updateApm.length === 0" :loading="appsLoading" />
</div>
</div>
</div>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import type { App } from "../global/typedefinition";
import { topByUpdate } from "../modules/ranking";
import RankingAppRow from "./RankingAppRow.vue";
import SourceLabel from "./SourceLabel.vue";
import EmptyState from "./RankingEmptyState.vue";
const props = defineProps<{
apps: App[];
apmRanking: App[];
sparkRanking: App[];
rankingLoading: boolean;
storeFilter?: "spark" | "apm" | "both";
}>();
const emit = defineEmits<{ (e: "open-detail", app: App): void }>();
const TOP = 10;
const showSpark = computed(() => props.storeFilter !== "apm");
const showApm = computed(() => props.storeFilter !== "spark");
const gridCols = computed(() =>
showSpark.value && showApm.value
? "grid-cols-1 sm:grid-cols-2"
: "grid-cols-1",
);
const appsLoading = computed(() => props.apps.length === 0);
const downloadSpark = computed(() => props.sparkRanking.slice(0, TOP));
const downloadApm = computed(() => props.apmRanking.slice(0, TOP));
const updateSpark = computed(() => topByUpdate(props.apps, "spark", TOP));
const updateApm = computed(() => topByUpdate(props.apps, "apm", TOP));
const onOpen = (app: App) => emit("open-detail", app);
</script>
+149 -3
View File
@@ -13,7 +13,7 @@
@click.self="closeModal" @click.self="closeModal"
> >
<div <div
class="relative w-full max-w-md overflow-hidden rounded-3xl border border-white/10 bg-white/95 shadow-2xl dark:border-slate-800 dark:bg-slate-900" class="relative flex max-h-[calc(100vh-2rem)] w-full max-w-md flex-col overflow-hidden rounded-3xl border border-white/10 bg-white/95 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
> >
<!-- 标题栏 --> <!-- 标题栏 -->
<div <div
@@ -35,8 +35,8 @@
</button> </button>
</div> </div>
<!-- 设置内容 --> <!-- 设置内容可滚动标题/底部固定 -->
<div class="p-6 space-y-4"> <div class="flex-1 overflow-y-auto p-6 space-y-4">
<!-- 更新检测开关 --> <!-- 更新检测开关 -->
<div <div
class="flex items-center justify-between rounded-2xl border border-slate-200/60 bg-slate-50/50 px-4 py-4 dark:border-slate-800/60 dark:bg-slate-800/50" class="flex items-center justify-between rounded-2xl border border-slate-200/60 bg-slate-50/50 px-4 py-4 dark:border-slate-800/60 dark:bg-slate-800/50"
@@ -118,6 +118,96 @@
/> />
</button> </button>
</div> </div>
<!-- 标签优先显示策略 -->
<div
class="rounded-2xl border border-slate-200/60 bg-slate-50/50 px-4 py-4 dark:border-slate-800/60 dark:bg-slate-800/50"
>
<div class="flex items-start gap-3">
<div
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-purple-100 text-purple-600 dark:bg-purple-900/30 dark:text-purple-400"
>
<i class="fas fa-tags"></i>
</div>
<div class="min-w-0 flex-1">
<p
class="text-sm font-medium text-slate-800 dark:text-slate-200"
>
标签优先显示策略
</p>
<p class="text-xs text-slate-500 dark:text-slate-400">
进入应用详情页时默认展示的来源标签
</p>
<div
class="mt-3 inline-flex w-full overflow-hidden rounded-lg border border-slate-200 dark:border-slate-700"
role="radiogroup"
aria-label="标签优先显示策略"
>
<button
v-for="opt in strategyOptions"
:key="opt.value"
type="button"
role="radio"
:aria-checked="tagPriorityStrategy === opt.value"
class="flex-1 px-2 py-1.5 text-xs font-medium transition-colors"
:class="
tagPriorityStrategy === opt.value
? 'bg-brand text-white'
: 'bg-white text-slate-500 hover:bg-slate-100 dark:bg-slate-700 dark:text-slate-400 dark:hover:bg-slate-600'
"
@click="selectStrategy(opt.value)"
>
{{ opt.label }}
</button>
</div>
</div>
</div>
</div>
<!-- 界面缩放整体缩放图标 / 间距 / 布局等比变化 -->
<div
class="rounded-2xl border border-slate-200/60 bg-slate-50/50 px-4 py-4 dark:border-slate-800/60 dark:bg-slate-800/50"
>
<div class="flex items-start gap-3">
<div
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-teal-100 text-teal-600 dark:bg-teal-900/30 dark:text-teal-400"
>
<i class="fas fa-search-plus"></i>
</div>
<div class="min-w-0 flex-1">
<p
class="text-sm font-medium text-slate-800 dark:text-slate-200"
>
界面缩放
</p>
<p class="text-xs text-slate-500 dark:text-slate-400">
整体放大或缩小界面图标间距布局等比变化
</p>
<div
class="mt-3 inline-flex w-full overflow-hidden rounded-lg border border-slate-200 dark:border-slate-700"
role="radiogroup"
aria-label="界面缩放"
>
<button
v-for="opt in uiScaleOptions"
:key="opt.value"
type="button"
role="radio"
:aria-checked="uiScale === opt.value"
class="flex-1 px-2 py-1.5 text-xs font-medium transition-colors"
:class="
uiScale === opt.value
? 'bg-brand text-white'
: 'bg-white text-slate-500 hover:bg-slate-100 dark:bg-slate-700 dark:text-slate-400 dark:hover:bg-slate-600'
"
@click="selectUiScale(opt.value)"
>
{{ opt.label }}
</button>
</div>
</div>
</div>
</div>
</div> </div>
<!-- 底部提示 --> <!-- 底部提示 -->
@@ -135,6 +225,18 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch, onMounted } from "vue"; import { ref, watch, onMounted } from "vue";
import {
getTagPriorityStrategy,
setTagPriorityStrategy,
type TagPriorityStrategy,
} from "../global/tagPriority";
import {
getUiScale,
setUiScale,
uiScaleToFactor,
UI_SCALE_OPTIONS,
type UiScaleOption,
} from "../global/displaySettings";
const props = defineProps<{ const props = defineProps<{
show: boolean; show: boolean;
@@ -149,6 +251,47 @@ interface Settings {
enableCreateDesktop: boolean; enableCreateDesktop: boolean;
} }
//
const strategyOptions: Array<{ value: TagPriorityStrategy; label: string }> = [
{ value: "auto", label: "自动选择" },
{ value: "spark", label: "Spark 优先" },
{ value: "apm", label: "APM 优先" },
];
// 90% / 100% / 110% / 125% / 150%
const uiScaleOptions = UI_SCALE_OPTIONS;
const tagPriorityStrategy = ref<TagPriorityStrategy>("auto");
//
const loadTagPriority = () => {
tagPriorityStrategy.value = getTagPriorityStrategy();
};
//
const selectStrategy = (value: TagPriorityStrategy) => {
tagPriorityStrategy.value = value;
setTagPriorityStrategy(value);
};
// Electron setZoomFactor//
const uiScale = ref<UiScaleOption>("100");
const loadUiScale = () => {
uiScale.value = getUiScale();
};
// IPC
const selectUiScale = async (value: UiScaleOption) => {
uiScale.value = value;
setUiScale(value);
try {
await window.ipcRenderer.invoke("set-zoom-factor", uiScaleToFactor(value));
} catch (error) {
console.error("应用界面缩放失败:", error);
}
};
const settings = ref<Settings>({ const settings = ref<Settings>({
enableUpdateCheck: true, enableUpdateCheck: true,
enableCreateDesktop: true, enableCreateDesktop: true,
@@ -208,6 +351,8 @@ watch(
(newVal) => { (newVal) => {
if (newVal) { if (newVal) {
loadSettings(); loadSettings();
loadTagPriority();
loadUiScale();
} }
}, },
); );
@@ -215,6 +360,7 @@ watch(
onMounted(() => { onMounted(() => {
if (props.show) { if (props.show) {
loadSettings(); loadSettings();
loadTagPriority();
} }
}); });
</script> </script>
+24
View File
@@ -0,0 +1,24 @@
<template>
<div
class="mb-1 flex items-center gap-1 text-xs font-semibold"
:class="
origin === 'spark'
? 'text-orange-600 dark:text-orange-400'
: 'text-blue-600 dark:text-blue-400'
"
>
<span
class="rounded px-1.5 py-0.5 text-[10px] font-bold uppercase"
:class="
origin === 'spark'
? 'bg-orange-100 dark:bg-orange-900/30'
: 'bg-blue-100 dark:bg-blue-900/30'
"
>{{ origin === "spark" ? "Spark" : "APM" }}</span
>
</div>
</template>
<script setup lang="ts">
defineProps<{ origin: "spark" | "apm" }>();
</script>
+318 -101
View File
@@ -1,9 +1,9 @@
<template> <template>
<div <div
class="h-screen overflow-y-auto bg-slate-50 dark:bg-slate-950 text-slate-900 dark:text-slate-100" class="flex h-screen flex-col overflow-hidden rounded-3xl bg-slate-50 dark:bg-slate-950 text-slate-900 dark:text-slate-100 ring-1 ring-black/5 dark:ring-white/5 shadow-[inset_0_1px_3px_rgba(0,0,0,0.25)]"
> >
<div <div
class="submitter-titlebar sticky top-0 z-30 border-b border-slate-200/70 bg-white px-4 py-3 dark:border-slate-800/70 dark:bg-slate-900" class="submitter-titlebar shrink-0 z-30 border-b border-slate-200/70 bg-white px-4 py-3 dark:border-slate-800/70 dark:bg-slate-900"
> >
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
@@ -14,17 +14,28 @@
</div> </div>
<h1 class="text-lg font-semibold">投稿应用</h1> <h1 class="text-lg font-semibold">投稿应用</h1>
</div> </div>
<button <div class="flex items-center gap-2">
type="button" <button
class="submitter-close-button inline-flex h-8 w-8 items-center justify-center rounded-lg text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800" type="button"
@click="closeWindow" class="submitter-close-button inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium text-blue-600 hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-slate-800"
> @click="openSubmissionQueue"
<i class="fas fa-times"></i> >
</button> <i class="fas fa-list"></i>
查看当前投稿队列
</button>
<button
type="button"
class="submitter-close-button inline-flex h-8 w-8 items-center justify-center rounded-lg text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800"
@click="closeWindow"
>
<i class="fas fa-times"></i>
</button>
</div>
</div> </div>
</div> </div>
<div class="p-6 max-w-2xl mx-auto"> <div class="flex-1 overflow-y-auto mb-6">
<div class="p-6 max-w-2xl mx-auto">
<div class="space-y-6"> <div class="space-y-6">
<div> <div>
<label <label
@@ -135,6 +146,19 @@
</div> </div>
</div> </div>
<div>
<label
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2"
>官网地址</label
>
<input
v-model="formData.website"
type="url"
placeholder="https://example.com"
class="w-full px-4 py-2.5 rounded-lg border border-slate-200 bg-white dark:bg-slate-800 dark:border-slate-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div> <div>
<label <label
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2"
@@ -161,19 +185,6 @@
/> />
</div> </div>
<div>
<label
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2"
>官网地址</label
>
<input
v-model="formData.website"
type="url"
placeholder="https://example.com"
class="w-full px-4 py-2.5 rounded-lg border border-slate-200 bg-white dark:bg-slate-800 dark:border-slate-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div> <div>
<label <label
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2"
@@ -215,12 +226,16 @@
>截图最多5张</label >截图最多5张</label
> >
<p class="mb-2 text-xs text-slate-500 dark:text-slate-400"> <p class="mb-2 text-xs text-slate-500 dark:text-slate-400">
可直接 Ctrl+V 粘贴截图 点击添加拖放或直接 Ctrl+V 粘贴 PNG 截图
</p> </p>
<div <div
tabindex="0" tabindex="0"
class="grid grid-cols-5 gap-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" class="grid grid-cols-5 gap-3 rounded-lg border-2 border-dashed border-transparent p-2 transition-colors hover:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
@paste="handleScreenshotPaste" @paste="handleScreenshotPaste"
@drop="handleScreenshotDrop"
@dragover.prevent
@dragenter.prevent
@dragleave.prevent
> >
<div <div
v-for="(screenshot, index) in formData.screenshots" v-for="(screenshot, index) in formData.screenshots"
@@ -247,11 +262,17 @@
<input <input
ref="screenshotInput" ref="screenshotInput"
type="file" type="file"
accept=".png,.jpg,.jpeg" accept=".png"
multiple multiple
class="hidden" class="hidden"
@change="handleScreenshotSelect" @change="handleScreenshotSelect"
/> />
<p
v-if="mediaError"
class="mt-2 text-sm text-red-600 dark:text-red-400"
>
{{ mediaError }}
</p>
</div> </div>
<div> <div>
@@ -536,7 +557,7 @@
<button <button
type="button" type="button"
class="flex-1 px-4 py-2 rounded-lg border border-slate-200 bg-white text-slate-700 dark:bg-slate-800 dark:border-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors" class="flex-1 px-4 py-2 rounded-lg border border-slate-200 bg-white text-slate-700 dark:bg-slate-800 dark:border-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors"
@click="resetFormAndClose" @click="continueSubmission"
> >
继续投递 继续投递
</button> </button>
@@ -587,7 +608,7 @@
class="fixed inset-0 z-50 flex items-center justify-center p-4" class="fixed inset-0 z-50 flex items-center justify-center p-4"
> >
<div <div
class="absolute inset-0 bg-black/50" class="absolute inset-0 bg-black/50 rounded-3xl"
@click="showArchPackDialog = false" @click="showArchPackDialog = false"
></div> ></div>
<div <div
@@ -620,11 +641,25 @@
class="w-full p-4 rounded-lg border-2 border-slate-200 dark:border-slate-700 hover:border-emerald-500 transition-colors text-left" class="w-full p-4 rounded-lg border-2 border-slate-200 dark:border-slate-700 hover:border-emerald-500 transition-colors text-left"
@click="selectPackArch(arch)" @click="selectPackArch(arch)"
> >
<div class="font-medium text-slate-900 dark:text-slate-100"> <div class="flex items-center gap-2">
{{ getArchDisplayName(arch.store) }} <span
class="rounded-full px-2 py-0.5 text-xs font-medium"
:class="
isSparkHistoryStore(arch.store)
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300'
"
>
{{ isSparkHistoryStore(arch.store) ? "Spark" : "APM" }}
</span>
<span class="font-medium text-slate-900 dark:text-slate-100">
{{ getArchDisplayName(arch.store) }}
</span>
</div> </div>
<div class="text-sm text-slate-500 dark:text-slate-400 mt-1"> <div class="text-sm text-slate-500 dark:text-slate-400 mt-1">
输出: {{ formData.pkgname }}-{{ arch.store }}.tar.gz 输出: {{ formData.pkgname.toLowerCase() }}-{{
arch.store
}}.tar.gz
</div> </div>
</button> </button>
</div> </div>
@@ -649,7 +684,7 @@
class="fixed inset-0 z-50 flex items-center justify-center p-4" class="fixed inset-0 z-50 flex items-center justify-center p-4"
> >
<div <div
class="absolute inset-0 bg-black/50" class="absolute inset-0 bg-black/50 rounded-3xl"
@click="showArchDialog = false" @click="showArchDialog = false"
></div> ></div>
<div <div
@@ -676,14 +711,23 @@
<div class="space-y-3 mb-6"> <div class="space-y-3 mb-6">
<button <button
v-for="arch in availableArchs" v-for="arch in sortedArchs"
:key="arch.store" :key="arch.store"
type="button" type="button"
class="w-full p-4 rounded-lg border-2 border-slate-200 dark:border-slate-700 hover:border-blue-500 transition-colors text-left" class="w-full p-4 rounded-lg border-2 transition-colors text-left"
:class="archOriginBorderClass(arch.store)"
@click="selectArch(arch)" @click="selectArch(arch)"
> >
<div class="font-medium text-slate-900 dark:text-slate-100"> <div class="flex items-center gap-2">
{{ getArchDisplayName(arch.store) }} <span class="font-medium text-slate-900 dark:text-slate-100">
{{ getArchDisplayName(arch.store) }}
</span>
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium"
:class="getOriginBadgeClass(arch.store)"
>
{{ getOriginLabel(arch.store) }}
</span>
</div> </div>
<div class="text-sm text-slate-500 dark:text-slate-400 mt-1"> <div class="text-sm text-slate-500 dark:text-slate-400 mt-1">
版本: {{ arch.version }} | 分类: {{ arch.category }} 版本: {{ arch.version }} | 分类: {{ arch.category }}
@@ -703,6 +747,7 @@
</div> </div>
</div> </div>
</Teleport> </Teleport>
</div>
</div> </div>
</template> </template>
@@ -767,6 +812,7 @@ const isSearchingHistory = ref(false);
const showArchDialog = ref(false); const showArchDialog = ref(false);
const availableArchs = ref<HistoryArchInfo[]>([]); const availableArchs = ref<HistoryArchInfo[]>([]);
const currentDebArch = ref(""); const currentDebArch = ref("");
const mediaError = ref("");
const iconPreview = ref(""); const iconPreview = ref("");
const iconFileName = ref(""); const iconFileName = ref("");
@@ -822,6 +868,23 @@ const isValidUrl = (url: string): boolean => {
} }
}; };
const getContributorFromGit = async (): Promise<string> => {
try {
const nameResult = await window.ipcRenderer.invoke("get-git-name");
const emailResult = await window.ipcRenderer.invoke("get-git-email");
const name = nameResult?.success ? nameResult.data : "";
const email = emailResult?.success ? emailResult.data : "";
if (name && email) {
return `${name} <${email}>`;
}
} catch (error) {
console.warn("[Submitter] Failed to get git config:", error);
}
return "";
};
const isFormValid = computed(() => { const isFormValid = computed(() => {
const hasRequiredFields = const hasRequiredFields =
formData.name.trim() && formData.name.trim() &&
@@ -864,13 +927,70 @@ const hasUserStartedFilling = computed(() => {
); );
}); });
const getArchDisplayName = (store: string): string => { const getHistoryArch = (
const archMap: Record<string, string> = { store: string,
store: "AMD64 (x86_64)", ): "amd64" | "arm64" | "loong64" | "other" => {
"aarch64-store": "ARM64 (aarch64)", if (store === "store" || store.startsWith("amd64-")) return "amd64";
"loong64-store": "LoongArch64", if (store === "aarch64-store" || store.startsWith("arm64-")) return "arm64";
if (store === "loong64-store" || store.startsWith("loong64-"))
return "loong64";
return "other";
};
const isSparkHistoryStore = (store: string): boolean => {
// amd64-store / arm64-store / loong64-store / amd64-apm ...
// store / aarch64-store / loong64-store
return store === "store" || store.endsWith("-store");
};
const sortHistoryArchs = (archs: HistoryArchInfo[]): HistoryArchInfo[] => {
const archOrder: Record<ReturnType<typeof getHistoryArch>, number> = {
amd64: 0,
arm64: 1,
loong64: 2,
other: 3,
}; };
return archMap[store] || store;
return [...archs].sort((a, b) => {
const sourceOrder =
Number(isSparkHistoryStore(b.store)) -
Number(isSparkHistoryStore(a.store));
if (sourceOrder !== 0) return sourceOrder;
return (
archOrder[getHistoryArch(a.store)] - archOrder[getHistoryArch(b.store)]
);
});
};
const getArchDisplayName = (store: string): string => {
const arch = getHistoryArch(store);
const archMap: Record<typeof arch, string> = {
amd64: "AMD64 (x86_64)",
arm64: "ARM64 (aarch64)",
loong64: "LoongArch64",
other: store,
};
return archMap[arch];
};
const sortedArchs = computed(() => sortHistoryArchs(availableArchs.value));
const getOriginLabel = (store: string): string => {
return isSparkHistoryStore(store) ? "Spark" : "APM";
};
const getOriginBadgeClass = (store: string): string => {
if (isSparkHistoryStore(store)) {
return "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400";
}
return "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400";
};
const archOriginBorderClass = (store: string): string => {
if (isSparkHistoryStore(store)) {
return "border-slate-200 dark:border-slate-700 hover:border-blue-500";
}
return "border-slate-200 dark:border-slate-700 hover:border-amber-400";
}; };
const loadCategoriesPromise = ref<Promise<void> | null>(null); const loadCategoriesPromise = ref<Promise<void> | null>(null);
@@ -1042,6 +1162,14 @@ const updateFormTags = () => {
formData.tags = selectedTags.value.map((t) => t.value).join(";"); formData.tags = selectedTags.value.map((t) => t.value).join(";");
}; };
const openSubmissionQueue = (): void => {
window.open(
"https://upload.spark-app.store/",
"_blank",
"noopener,noreferrer",
);
};
const selectDebFile = async () => { const selectDebFile = async () => {
const result = await window.ipcRenderer.invoke("select-deb-file"); const result = await window.ipcRenderer.invoke("select-deb-file");
if (result?.success && result.filePath) { if (result?.success && result.filePath) {
@@ -1091,14 +1219,8 @@ const searchHistoryApp = async () => {
console.log("[Submitter] availableArchs before:", availableArchs.value); console.log("[Submitter] availableArchs before:", availableArchs.value);
console.log("[Submitter] showArchDialog before:", showArchDialog.value); console.log("[Submitter] showArchDialog before:", showArchDialog.value);
// amd64 arm64 loong64 availableArchs.value = sortHistoryArchs(
const archOrder: Record<string, number> = { historyResult.data as HistoryArchInfo[],
store: 0,
"aarch64-store": 1,
"loong64-store": 2,
};
availableArchs.value = [...historyResult.data].sort(
(a, b) => (archOrder[a.store] ?? 99) - (archOrder[b.store] ?? 99),
); );
console.log("[Submitter] availableArchs after:", availableArchs.value); console.log("[Submitter] availableArchs after:", availableArchs.value);
console.log( console.log(
@@ -1112,7 +1234,7 @@ const searchHistoryApp = async () => {
} }
// //
const firstArch = historyResult.data[0]; const firstArch = availableArchs.value[0];
if (firstArch) { if (firstArch) {
formData.name = firstArch.name || formData.name; formData.name = firstArch.name || formData.name;
formData.category = firstArch.category || formData.category; formData.category = firstArch.category || formData.category;
@@ -1183,6 +1305,15 @@ const searchHistoryApp = async () => {
const parseDebFileAndSearchHistory = async (debPath: string) => { const parseDebFileAndSearchHistory = async (debPath: string) => {
isParsingDeb.value = true; isParsingDeb.value = true;
debParseError.value = ""; debParseError.value = "";
availableArchs.value = [];
showArchDialog.value = false;
formData.pkgname = "";
formData.version = "";
formData.author = "";
formData.contributor = "";
formData.website = "";
formData.description = "";
currentDebArch.value = "";
try { try {
console.log( console.log(
@@ -1231,6 +1362,12 @@ const parseDebFileAndSearchHistory = async (debPath: string) => {
JSON.stringify(formData, null, 2), JSON.stringify(formData, null, 2),
); );
const gitContributor = await getContributorFromGit();
if (gitContributor) {
formData.contributor = gitContributor;
console.log("[Submitter] Contributor updated from git:", gitContributor);
}
if (formData.pkgname) { if (formData.pkgname) {
isSearchingHistory.value = true; isSearchingHistory.value = true;
try { try {
@@ -1294,13 +1431,20 @@ const handleDrop = async (event: DragEvent) => {
if (file.name.endsWith(".deb")) { if (file.name.endsWith(".deb")) {
console.log("[Submitter] File is a deb package"); console.log("[Submitter] File is a deb package");
const textUriList = event.dataTransfer?.getData("text/uri-list"); // contextIsolation File.path
console.log("[Submitter] text/uri-list:", textUriList); // 使 Electron webUtils.getPathForFile()
let filePath: string;
const textPlain = event.dataTransfer?.getData("text/plain"); try {
console.log("[Submitter] text/plain:", textPlain); filePath = window.electronUtils.getPathForFile(file);
console.log("[Submitter] File path from electronUtils:", filePath);
const filePath = file.path || textUriList || textPlain; } catch {
console.warn(
"[Submitter] electronUtils.getPathForFile failed, trying fallback",
);
const textUriList = event.dataTransfer?.getData("text/uri-list");
const textPlain = event.dataTransfer?.getData("text/plain");
filePath = textUriList || textPlain || "";
}
console.log("[Submitter] Final filePath:", filePath); console.log("[Submitter] Final filePath:", filePath);
if (filePath) { if (filePath) {
@@ -1339,7 +1483,9 @@ const selectArch = async (arch: HistoryArchInfo) => {
formData.name = arch.name || formData.name; formData.name = arch.name || formData.name;
formData.author = arch.author || formData.author; formData.author = arch.author || formData.author;
formData.contributor = arch.contributor || formData.contributor; if (!formData.contributor) {
formData.contributor = arch.contributor || "";
}
formData.website = arch.website || formData.website; formData.website = arch.website || formData.website;
formData.category = arch.category || formData.category; formData.category = arch.category || formData.category;
formData.description = arch.more || formData.description; formData.description = arch.more || formData.description;
@@ -1397,38 +1543,46 @@ const selectIconFile = () => {
iconFileInput.value?.click(); iconFileInput.value?.click();
}; };
const isPngFile = (file: File): boolean => {
return (
file.type === "image/png" || (file.type === "" && /\.png$/i.test(file.name))
);
};
const readIconFile = (file: File): void => {
iconFileName.value = file.name;
const reader = new FileReader();
reader.onload = (e) => {
const dataUrl = e.target?.result as string;
formData.iconPath = dataUrl;
iconPreview.value = dataUrl;
};
reader.readAsDataURL(file);
};
const handleIconFileSelect = (event: Event) => { const handleIconFileSelect = (event: Event) => {
const target = event.target as HTMLInputElement; const target = event.target as HTMLInputElement;
const file = target.files?.[0]; const file = target.files?.[0];
if (file) { if (file) {
iconFileName.value = file.name; if (isPngFile(file)) {
const reader = new FileReader(); mediaError.value = "";
reader.onload = (e) => { readIconFile(file);
const dataUrl = e.target?.result as string; } else {
formData.iconPath = dataUrl; mediaError.value = "应用图标仅支持 PNG 格式。";
iconPreview.value = dataUrl; }
};
reader.readAsDataURL(file);
} }
target.value = "";
}; };
const handleIconDrop = (event: DragEvent) => { const handleIconDrop = (event: DragEvent) => {
event.preventDefault(); event.preventDefault();
const file = event.dataTransfer?.files?.[0]; const file = event.dataTransfer?.files?.[0];
if ( if (!file) return;
file && if (isPngFile(file)) {
(file.name.endsWith(".png") || mediaError.value = "";
file.name.endsWith(".jpg") || readIconFile(file);
file.name.endsWith(".jpeg")) } else {
) { mediaError.value = "应用图标仅支持 PNG 格式。";
iconFileName.value = file.name;
const reader = new FileReader();
reader.onload = (e) => {
const dataUrl = e.target?.result as string;
formData.iconPath = dataUrl;
iconPreview.value = dataUrl;
};
reader.readAsDataURL(file);
} }
}; };
@@ -1437,7 +1591,14 @@ const addScreenshot = () => {
}; };
const importScreenshots = (files: File[]) => { const importScreenshots = (files: File[]) => {
for (const file of files) { const pngFiles = files.filter(isPngFile);
if (pngFiles.length !== files.length) {
mediaError.value = "截图仅支持 PNG 格式。";
} else if (pngFiles.length > 0) {
mediaError.value = "";
}
for (const file of pngFiles) {
if (formData.screenshots.length >= 5) break; if (formData.screenshots.length >= 5) break;
const reader = new FileReader(); const reader = new FileReader();
@@ -1455,16 +1616,18 @@ const importScreenshots = (files: File[]) => {
const handleScreenshotSelect = (event: Event) => { const handleScreenshotSelect = (event: Event) => {
const target = event.target as HTMLInputElement; const target = event.target as HTMLInputElement;
const files = Array.from(target.files ?? []).filter((file) => importScreenshots(Array.from(target.files ?? []));
/\.(png|jpe?g)$/i.test(file.name),
);
importScreenshots(files);
target.value = ""; target.value = "";
}; };
const handleScreenshotDrop = (event: DragEvent) => {
event.preventDefault();
importScreenshots(Array.from(event.dataTransfer?.files ?? []));
};
const handleScreenshotPaste = (event: ClipboardEvent) => { const handleScreenshotPaste = (event: ClipboardEvent) => {
const files = Array.from(event.clipboardData?.items ?? []) const files = Array.from(event.clipboardData?.items ?? [])
.filter((item) => item.type.startsWith("image/")) .filter((item) => item.kind === "file")
.map((item) => item.getAsFile()) .map((item) => item.getAsFile())
.filter((file): file is File => file !== null); .filter((file): file is File => file !== null);
@@ -1497,6 +1660,7 @@ const resetForm = () => {
submitSuccess.value = false; submitSuccess.value = false;
submitError.value = ""; submitError.value = "";
debParseError.value = ""; debParseError.value = "";
mediaError.value = "";
packageSuccess.value = false; packageSuccess.value = false;
packageError.value = ""; packageError.value = "";
packageResult.value = null; packageResult.value = null;
@@ -1514,14 +1678,29 @@ const resetForm = () => {
isSearchingHistory.value = false; isSearchingHistory.value = false;
}; };
const closeSubmitSuccessModal = () => { const clearSubmitTransientState = (): void => {
showSubmitSuccessModal.value = false; showSubmitSuccessModal.value = false;
resetForm(); submitSuccess.value = false;
isSubmitting.value = false;
uploadProgress.value = 0;
uploadStage.value = "";
uploadStageMessage.value = "";
uploadStages.value = [];
}; };
const resetFormAndClose = () => { const closeSubmitSuccessModal = () => {
showSubmitSuccessModal.value = false; clearSubmitTransientState();
resetForm(); };
const continueSubmission = () => {
const shouldClear = window.confirm(
"是否清空当前表单内容后继续投递?选择“取消”将保留已填写内容。",
);
if (shouldClear) {
resetForm();
} else {
clearSubmitTransientState();
}
}; };
const submitForm = async () => { const submitForm = async () => {
@@ -1712,26 +1891,64 @@ const closeWindow = () => {
window.ipcRenderer.send("close-submitter-window"); window.ipcRenderer.send("close-submitter-window");
}; };
import { onMounted, nextTick } from "vue"; import { onMounted, onUnmounted, nextTick } from "vue";
const getGitEmail = async () => { const getGitInfo = async () => {
try { try {
const result = await window.ipcRenderer.invoke("get-git-email"); const [nameResult, emailResult] = await Promise.all([
if (result?.success && result.data) { window.ipcRenderer.invoke("get-git-name"),
formData.mail = result.data; window.ipcRenderer.invoke("get-git-email"),
console.log("[Submitter] Git email auto-filled:", result.data); ]);
let gitName = "";
let gitEmail = "";
if (nameResult?.success && nameResult.data) {
gitName = nameResult.data;
console.log("[Submitter] Git name auto-filled:", gitName);
}
if (emailResult?.success && emailResult.data) {
gitEmail = emailResult.data;
console.log("[Submitter] Git email auto-filled:", gitEmail);
}
// git name email contributor : Name <email>
if (gitName || gitEmail) {
if (gitName && gitEmail) {
formData.contributor = `${gitName} <${gitEmail}>`;
} else if (gitName) {
formData.contributor = gitName;
} else if (gitEmail) {
formData.contributor = gitEmail;
}
}
//
if (gitEmail) {
formData.mail = gitEmail;
} }
} catch (err) { } catch (err) {
console.warn("[Submitter] Failed to get git email:", err); console.warn("[Submitter] Failed to get git info:", err);
} }
}; };
onMounted(async () => { onMounted(async () => {
console.log("[Submitter] Component mounted, loading categories and tags"); console.log("[Submitter] Component mounted, loading categories and tags");
// body/html
// onUnmounted
const prevHtmlBg = document.documentElement.style.backgroundColor;
const prevBodyBg = document.body.style.backgroundColor;
document.documentElement.style.backgroundColor = "transparent";
document.body.style.backgroundColor = "transparent";
onUnmounted(() => {
document.documentElement.style.backgroundColor = prevHtmlBg;
document.body.style.backgroundColor = prevBodyBg;
});
// //
await Promise.all([loadCategoriesList(), loadTagsList()]); await Promise.all([loadCategoriesList(), loadTagsList()]);
// git // git name email contributor mail
await getGitEmail(); await getGitInfo();
}); });
</script> </script>
+44
View File
@@ -0,0 +1,44 @@
<template>
<div
class="relative mb-6 overflow-hidden rounded-2xl border border-amber-200/60 bg-gradient-to-r from-amber-50 to-orange-50 p-5 shadow-sm dark:border-amber-500/20 dark:from-amber-500/10 dark:to-orange-500/10"
>
<div
class="absolute -right-4 -top-4 text-7xl text-amber-200/50 dark:text-amber-400/10"
>
<i class="fas fa-heart"></i>
</div>
<div class="flex items-start gap-3">
<div
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-amber-400 to-orange-500 text-white shadow"
>
<i class="fas fa-hand-holding-heart"></i>
</div>
<div>
<p
class="text-sm font-bold text-amber-700 dark:text-amber-300"
>
{{ title }}
</p>
<p
class="mt-1 text-sm leading-relaxed text-slate-600 dark:text-slate-300"
>
{{ message }}
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
title?: string;
message?: string;
}>(),
{
title: "致每一位星火贡献者",
message:
"星火应用商店的每一次更新、每一个应用,都离不开开发者的默默耕耘与热心用户的悉心维护。感谢所有为星火生态付出时间与精力的贡献者——是你们,让开源之光生生不息。🎉",
},
);
</script>
+43 -5
View File
@@ -58,6 +58,34 @@
{{ error }} {{ error }}
</div> </div>
<!-- 双来源先让用户选择要卸载的来源 -->
<div
v-if="installOrigins.length > 1 && !uninstalling && !completed"
class="mb-1 flex w-full flex-col items-end gap-2"
>
<p class="text-sm text-slate-500 dark:text-slate-400">
该应用同时通过 APM Spark 安装请选择要卸载的来源
</p>
<div class="flex gap-2">
<button
type="button"
class="inline-flex items-center gap-2 rounded-xl border border-amber-300/70 px-4 py-2 text-sm font-semibold text-amber-700 transition hover:bg-amber-50 dark:border-amber-500/40 dark:text-amber-300 dark:hover:bg-amber-500/10"
@click="confirmUninstall('apm')"
>
<i class="fas fa-box-open"></i>
卸载 APM
</button>
<button
type="button"
class="inline-flex items-center gap-2 rounded-xl border border-sky-300/70 px-4 py-2 text-sm font-semibold text-sky-700 transition hover:bg-sky-50 dark:border-sky-500/40 dark:text-sky-300 dark:hover:bg-sky-500/10"
@click="confirmUninstall('spark')"
>
<i class="fas fa-bolt"></i>
卸载 Spark
</button>
</div>
</div>
<div class="flex items-center justify-end gap-3"> <div class="flex items-center justify-end gap-3">
<button <button
v-if="!uninstalling" v-if="!uninstalling"
@@ -69,10 +97,10 @@
</button> </button>
<button <button
v-if="!uninstalling && !completed" v-if="installOrigins.length <= 1 && !uninstalling && !completed"
type="button" type="button"
class="inline-flex items-center gap-2 rounded-xl bg-rose-500 px-4 py-2 text-sm font-semibold text-white shadow-lg shadow-rose-500/30 transition hover:bg-rose-600 hover:-translate-y-0.5" class="inline-flex items-center gap-2 rounded-xl bg-rose-500 px-4 py-2 text-sm font-semibold text-white shadow-lg shadow-rose-500/30 transition hover:bg-rose-600 hover:-translate-y-0.5"
@click="confirmUninstall" @click="confirmUninstall(installOrigins[0])"
> >
<i class="fas fa-trash"></i> <i class="fas fa-trash"></i>
确认卸载 确认卸载
@@ -115,6 +143,14 @@ const logEnd = ref<HTMLElement | null>(null);
const appName = computed(() => props.app?.name || "未知应用"); const appName = computed(() => props.app?.name || "未知应用");
const appPkg = computed(() => props.app?.pkgname || ""); const appPkg = computed(() => props.app?.pkgname || "");
// origins退 origin
const installOrigins = computed<Array<"spark" | "apm">>(() => {
if (props.app?.origins && props.app.origins.length > 0) {
return props.app.origins;
}
return [props.app?.origin ?? "spark"];
});
const handleClose = () => { const handleClose = () => {
if (uninstalling.value && !completed.value) return; // Prevent closing while uninstalling if (uninstalling.value && !completed.value) return; // Prevent closing while uninstalling
reset(); reset();
@@ -134,18 +170,20 @@ const reset = () => {
error.value = ""; error.value = "";
}; };
const confirmUninstall = () => { const confirmUninstall = (origin: "spark" | "apm") => {
if (!appPkg.value) { if (!appPkg.value) {
error.value = "无效的包名"; error.value = "无效的包名";
return; return;
} }
uninstalling.value = true; uninstalling.value = true;
logs.value = ["正在请求卸载: " + appPkg.value + "..."]; const originLabel = origin === "apm" ? "APM" : "Spark";
logs.value = [`正在请求卸载(${originLabel}): ${appPkg.value}...`];
// spark -> aptss removeapm -> apm autoremove
window.ipcRenderer.send("remove-installed", { window.ipcRenderer.send("remove-installed", {
pkgname: appPkg.value, pkgname: appPkg.value,
origin: props.app?.origin || "spark", origin,
}); });
}; };
+24
View File
@@ -20,6 +20,7 @@
<UpdateCenterToolbar <UpdateCenterToolbar
:search-query="store.searchQuery.value" :search-query="store.searchQuery.value"
:selected-count="selectedCount" :selected-count="selectedCount"
:selectable-count="store.selectableCount.value"
:all-selected="store.allSelected.value" :all-selected="store.allSelected.value"
:some-selected="store.someSelected.value" :some-selected="store.someSelected.value"
:loading="store.loading.value" :loading="store.loading.value"
@@ -61,12 +62,24 @@
> >
正在刷新更新列表 正在刷新更新列表
</div> </div>
<div
v-if="heldLockedCount > 0"
class="mx-6 mt-4 rounded-2xl border border-amber-200 bg-amber-50/80 px-4 py-3 text-sm text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100"
>
<p class="leading-6">
检测到 {{ heldLockedCount }} 个软件被系统锁定hold默认不会升级如需强制升级请在对应软件上打开强制安装开关
</p>
</div>
<div class="flex min-h-0 flex-1"> <div class="flex min-h-0 flex-1">
<UpdateCenterList <UpdateCenterList
:items="store.filteredItems.value" :items="store.filteredItems.value"
:tasks="store.snapshot.value.tasks" :tasks="store.snapshot.value.tasks"
:selected-task-keys="store.selectedTaskKeys.value" :selected-task-keys="store.selectedTaskKeys.value"
:forced-task-keys="store.forcedTaskKeys.value"
:apps="apps"
@toggle-selection="emit('toggle-selection', $event)" @toggle-selection="emit('toggle-selection', $event)"
@toggle-force="store.toggleForce"
@ignore-item="store.ignoreItem" @ignore-item="store.ignoreItem"
@unignore-item="store.unignoreItem" @unignore-item="store.unignoreItem"
/> />
@@ -86,6 +99,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from "vue"; import { computed } from "vue";
import type { App } from "@/global/typedefinition";
import type { UpdateCenterStore } from "@/modules/updateCenter"; import type { UpdateCenterStore } from "@/modules/updateCenter";
import UpdateCenterList from "./update-center/UpdateCenterList.vue"; import UpdateCenterList from "./update-center/UpdateCenterList.vue";
@@ -103,10 +117,20 @@ const emit = defineEmits<{
const props = defineProps<{ const props = defineProps<{
show: boolean; show: boolean;
store: UpdateCenterStore; store: UpdateCenterStore;
apps: App[];
}>(); }>();
const selectedCount = computed(() => props.store.getSelectedItems().length); const selectedCount = computed(() => props.store.getSelectedItems().length);
// held
const heldLockedCount = computed(
() =>
props.store.snapshot.value.items.filter(
(i) =>
i.held === true && !props.store.forcedTaskKeys.value.has(i.taskKey),
).length,
);
const onOverlayWheel = (e: WheelEvent) => { const onOverlayWheel = (e: WheelEvent) => {
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
if (target.closest(".overflow-y-auto, .overflow-auto")) return; if (target.closest(".overflow-y-auto, .overflow-auto")) return;
+125 -15
View File
@@ -1,45 +1,154 @@
<template> <template>
<header <header
class="window-titlebar sticky top-0 z-20 flex h-10 shrink-0 items-center justify-between border-b border-slate-200/70 bg-white px-3 text-slate-700 dark:border-slate-800/70 dark:bg-slate-950 dark:text-slate-200" class="window-titlebar sticky top-0 z-20 flex h-12 shrink-0 items-center gap-3 border-b border-slate-200/70 bg-slate-50 px-4 text-slate-700 dark:border-slate-800/70 dark:bg-slate-950 dark:text-slate-200"
> >
<div class="flex min-w-0 items-center gap-2"> <!-- 移动端侧栏切换 + logo + 应用名 -->
<span class="h-3 w-3 rounded-full bg-[#2b7fff]"></span> <div class="window-titlebar-left flex min-w-0 items-center gap-2">
<span class="truncate text-sm font-semibold">星火应用商店</span>
</div>
<div class="window-titlebar-controls flex items-center gap-1">
<button <button
type="button" type="button"
class="rounded-md px-3 py-1 text-sm transition hover:bg-slate-200/80 dark:hover:bg-slate-800" class="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-md text-slate-500 transition hover:bg-slate-200/80 lg:hidden dark:text-slate-400 dark:hover:bg-slate-800"
aria-label="切换侧边栏"
title="切换侧边栏"
@click="$emit('toggle-sidebar')"
>
<i class="fas fa-bars text-base"></i>
</button>
<img
src="../assets/imgs/spark-store.svg"
class="h-8 w-8 flex-shrink-0"
alt="星火应用商店"
/>
<span class="truncate text-base font-semibold">星火应用商店</span>
<span
class="rounded-full border border-orange-400 bg-orange-50 px-2 py-0.5 text-[10px] font-semibold text-orange-600 dark:border-amber-400/70 dark:bg-amber-400/10 dark:text-amber-300 dark:shadow-[0_0_8px_rgba(251,191,36,0.4)]"
>社区版</span
>
</div>
<!-- 搜索框取代原 AppHeader 里的搜索 -->
<div class="window-titlebar-center min-w-0 flex-1">
<div class="relative mx-auto w-full max-w-xl">
<i
class="fas fa-search pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-slate-400"
></i>
<input
id="searchBox"
:value="searchQuery"
class="w-full rounded-full border border-slate-200/70 bg-white/70 py-2 pl-9 pr-9 text-sm text-slate-700 shadow-sm outline-none transition placeholder:text-slate-400 focus:border-brand/50 focus:ring-2 focus:ring-brand/10 dark:border-slate-800/70 dark:bg-slate-900/60 dark:text-slate-200"
placeholder="搜索应用名 / 包名 / 标签 / 粘贴 SPK 分享链接"
@focus="handleFocus"
@input="handleInput"
/>
<button
v-if="searchQuery"
type="button"
class="absolute flex right-2.5 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-300"
title="清除搜索"
@click="clearSearch"
>
<i class="fas fa-times-circle text-sm"></i>
</button>
</div>
</div>
<!-- 主题 + 设置 + 关于 + 分隔符 + 窗口控制 -->
<div class="window-titlebar-controls flex shrink-0 items-center gap-1">
<ThemeToggle :theme-mode="themeMode" @toggle="$emit('toggle-theme')" />
<button
type="button"
class="inline-flex h-9 w-9 items-center justify-center rounded-md text-slate-500 transition hover:bg-slate-200/80 dark:text-slate-400 dark:hover:bg-slate-800"
aria-label="安装设置"
title="安装设置"
@click="$emit('open-install-settings')"
>
<i class="fas fa-cog text-base"></i>
</button>
<button
type="button"
class="inline-flex h-9 w-9 items-center justify-center rounded-md text-slate-500 transition hover:bg-slate-200/80 dark:text-slate-400 dark:hover:bg-slate-800"
aria-label="关于"
title="关于"
@click="$emit('open-about')"
>
<i class="fas fa-info-circle text-base"></i>
</button>
<div class="mx-1 h-6 w-px bg-slate-300/70 dark:bg-slate-700/70"></div>
<button
type="button"
class="fas fa-window-minimize rounded-md px-3 py-1.5 text-base transition hover:bg-slate-200/80 dark:hover:bg-slate-800"
aria-label="最小化" aria-label="最小化"
title="最小化" title="最小化"
@click="minimize" @click="minimize"
> >
</button> </button>
<button <button
type="button" type="button"
class="rounded-md px-3 py-1 text-sm transition hover:bg-slate-200/80 dark:hover:bg-slate-800" class="fas rounded-md px-3 py-1.5 text-base transition hover:bg-slate-200/80 dark:hover:bg-slate-800"
:class="isMaximized ? 'fa-window-restore' : 'fa-window-maximize'"
aria-label="最大化或还原" aria-label="最大化或还原"
title="最大化或还原" title="最大化或还原"
@click="toggleMaximize" @click="toggleMaximize"
> >
</button> </button>
<button <button
type="button" type="button"
class="rounded-md px-3 py-1 text-sm transition hover:bg-red-500 hover:text-white" class="fas fa-times rounded-md px-3 py-1.5 text-base transition hover:bg-red-500 hover:text-white"
aria-label="关闭" aria-label="关闭"
title="关闭" title="关闭"
@click="close" @click="close"
> >
×
</button> </button>
</div> </div>
</header> </header>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import ThemeToggle from "./ThemeToggle.vue";
defineProps<{
searchQuery: string;
themeMode: "light" | "dark" | "auto";
isMaximized: boolean
}>();
const emit = defineEmits<{
(e: "update:searchQuery", value: string): void;
(e: "search-focus"): void;
(e: "spk-link", pkgname: string): void;
(e: "open-install-settings"): void;
(e: "open-about"): void;
(e: "toggle-theme"): void;
(e: "toggle-sidebar"): void;
}>();
const handleFocus = () => {
emit("search-focus");
};
const handleInput = (event: Event) => {
const target = event.target as HTMLInputElement | null;
if (!target) return;
const value = target.value.trim();
// SPK : spk://search/{pkgname} spk://store/{category}/{pkgname}
const spkMatch =
value.match(/^spk:\/\/search\/(.+)$/i) ||
value.match(/^spk:\/\/store\/[^/]+\/(.+)$/i);
if (spkMatch) {
const pkgname = spkMatch[1].trim();
if (pkgname) {
target.value = "";
emit("update:searchQuery", "");
emit("spk-link", pkgname);
return;
}
}
emit("update:searchQuery", target.value);
};
const clearSearch = () => {
emit("update:searchQuery", "");
};
const minimize = () => { const minimize = () => {
window.windowControls.minimize(); window.windowControls.minimize();
}; };
@@ -58,8 +167,9 @@ const close = () => {
-webkit-app-region: drag; -webkit-app-region: drag;
} }
.window-titlebar-controls, /* 所有可交互元素(输入框、按钮)禁用窗口拖动 */
.window-titlebar-controls button { .window-titlebar input,
.window-titlebar button {
-webkit-app-region: no-drag; -webkit-app-region: no-drag;
} }
</style> </style>
+167 -84
View File
@@ -1,100 +1,151 @@
<template> <template>
<label <label
class="flex flex-col gap-4 rounded-2xl border border-slate-200/70 bg-white/90 p-4 shadow-sm dark:border-slate-800/70 dark:bg-slate-900/70" class="relative flex items-center gap-3 rounded-xl border p-2.5 shadow-sm transition"
:class="
item.ignored === true
? 'border-slate-200/50 bg-slate-50/60 opacity-70 dark:border-slate-800/50 dark:bg-slate-900/40'
: item.held === true
? 'border-amber-300/70 bg-amber-50/70 dark:border-amber-500/30 dark:bg-amber-500/10'
: 'border-slate-200/70 bg-white/90 dark:border-slate-800/70 dark:bg-slate-900/70'
"
> >
<div class="flex items-start gap-3"> <input
<input type="checkbox"
type="checkbox" class="h-4 w-4 shrink-0 rounded border-slate-300 accent-brand focus:ring-brand"
class="mt-1 h-4 w-4 rounded border-slate-300 accent-brand focus:ring-brand" :checked="selected"
:checked="selected" :disabled="isSelectionLocked"
:disabled="item.ignored === true" @change="$emit('toggle-selection')"
@change="$emit('toggle-selection')" />
<div
class="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-md bg-slate-100 text-slate-400 dark:bg-slate-800 dark:text-slate-500"
:class="item.ignored === true || item.held === true ? 'opacity-60' : ''"
>
<img
:src="iconSrc"
:alt="`${item.displayName} 图标`"
class="h-full w-full object-cover"
@error="handleIconError"
/> />
<div </div>
class="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-slate-100 text-slate-400 dark:bg-slate-800 dark:text-slate-500"
> <div class="min-w-0 flex-1">
<img <div class="flex flex-wrap items-center gap-2">
:src="iconSrc"
:alt="`${item.displayName} 图标`"
class="h-full w-full object-cover"
@error="handleIconError"
/>
</div>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<p class="font-semibold text-slate-900 dark:text-white">
{{ item.displayName }}
</p>
<span
class="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-semibold text-slate-600 dark:bg-slate-800 dark:text-slate-200"
>
{{ sourceLabel }}
</span>
<span
v-if="item.isMigration"
class="rounded-full bg-brand/10 px-2.5 py-1 text-xs font-semibold text-brand"
>
将迁移到 APM
</span>
<span
v-if="item.ignored === true"
class="rounded-full bg-slate-200 px-2.5 py-1 text-xs font-semibold text-slate-500 dark:bg-slate-800 dark:text-slate-300"
>
已忽略
</span>
</div>
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">
{{ item.packageName }} · 当前 {{ item.currentVersion }} · 更新至
{{ item.newVersion }}
</p>
<p <p
v-if="item.ignored === true" class="truncate font-semibold"
class="mt-2 text-xs font-medium text-slate-500 dark:text-slate-400" :class="
item.ignored === true
? 'text-sm text-slate-400 dark:text-slate-500'
: 'text-sm text-slate-900 dark:text-white'
"
> >
已忽略的更新不会加入本次任务 {{ item.displayName }}
</p> </p>
<span
class="shrink-0 rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-semibold text-slate-600 dark:bg-slate-800 dark:text-slate-200"
>
{{ sourceLabel }}
</span>
<span
v-if="item.ignored === true"
class="shrink-0 rounded-full bg-slate-200 px-2 py-0.5 text-[10px] font-semibold text-slate-500 dark:bg-slate-800 dark:text-slate-300"
>
已忽略
</span>
<span
v-else-if="item.held === true && !forced"
class="shrink-0 rounded-full bg-amber-200 px-2 py-0.5 text-[10px] font-semibold text-amber-700 dark:bg-amber-500/20 dark:text-amber-200"
>
已锁定
</span>
<span
v-else-if="item.held === true && forced"
class="shrink-0 rounded-full bg-amber-500 px-2 py-0.5 text-[10px] font-semibold text-white dark:bg-amber-500"
>
将强制
</span>
</div> </div>
<div <p class="mt-0.5 truncate text-xs text-slate-500 dark:text-slate-400">
v-if="task" {{ item.packageName }} · {{ item.currentVersion }}
class="text-right text-sm font-semibold text-slate-600 dark:text-slate-300" {{ item.newVersion }}
> </p>
<p>{{ statusLabel }}</p>
<p v-if="showProgress" class="mt-1">{{ progressText }}</p>
</div>
</div> </div>
<div class="flex justify-end"> <div
<button v-if="task"
v-if="item.ignored === true" class="shrink-0 text-right text-xs font-semibold text-slate-600 dark:text-slate-300"
type="button" >
class="inline-flex items-center gap-2 rounded-2xl border border-slate-300/80 px-3 py-2 text-sm font-semibold text-slate-600 transition hover:bg-slate-50 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800" <p>{{ statusLabel }}</p>
aria-label="取消忽略" <p v-if="showProgress" class="mt-0.5">{{ progressText }}</p>
@click.stop="$emit('unignore-item')"
>
<i class="fas fa-rotate-left"></i>
取消忽略
</button>
<button
v-else
type="button"
class="inline-flex items-center gap-2 rounded-2xl border border-amber-300/80 px-3 py-2 text-sm font-semibold text-amber-700 transition hover:bg-amber-50 dark:border-amber-500/40 dark:text-amber-300 dark:hover:bg-amber-500/10"
aria-label="忽略更新"
@click.stop="$emit('ignore-item')"
>
<i class="fas fa-eye-slash"></i>
忽略更新
</button>
</div> </div>
<div v-if="showProgress" class="space-y-2"> <div
<div v-if="item.ignored !== true && !task"
class="h-2 overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800" class="flex shrink-0 flex-col items-end gap-0.5"
>
<span
class="text-[11px] font-semibold text-brand dark:text-amber-300"
>{{ timeLabel }}</span
> >
<div <span class="text-[10px] text-slate-400">{{ sizeLabel }}</span>
class="h-full rounded-full bg-gradient-to-r from-brand to-brand-dark" </div>
:style="progressStyle" <div v-else-if="!task" class="flex shrink-0 flex-col items-end gap-0.5">
></div> <span class="text-[11px] font-semibold text-slate-400">{{
</div> timeLabel
}}</span>
<span class="text-[10px] text-slate-400">{{ sizeLabel }}</span>
</div>
<!-- 强制安装开关仅对被系统锁定held的包显示 -->
<div
v-if="item.held === true && !task"
class="flex shrink-0 items-center gap-1"
:title="forced ? '已开启强制安装' : '强制安装被锁定的软件'"
>
<button
type="button"
class="relative inline-flex h-[18px] w-[34px] items-center rounded-full transition"
:class="forced ? 'bg-brand' : 'bg-slate-300 dark:bg-slate-600'"
role="switch"
:aria-checked="forced"
aria-label="强制安装"
@click.stop="$emit('toggle-force')"
>
<span
class="inline-block h-3.5 w-3.5 transform rounded-full bg-white transition"
:class="forced ? 'translate-x-[16px]' : 'translate-x-[2px]'"
></span>
</button>
<span class="text-[10px] text-slate-400">强制</span>
</div>
<button
v-if="item.ignored === true"
type="button"
class="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-slate-300/80 text-slate-500 transition hover:bg-slate-50 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800"
aria-label="取消忽略"
@click.stop="$emit('unignore-item')"
>
<i class="fas fa-rotate-left text-xs"></i>
</button>
<button
v-else
type="button"
class="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-amber-600 transition hover:bg-amber-50 dark:text-amber-300 dark:hover:bg-amber-500/10"
aria-label="忽略更新"
@click.stop="$emit('ignore-item')"
>
<i class="fas fa-eye-slash text-xs"></i>
</button>
<div
v-if="showProgress"
class="absolute inset-x-0 bottom-0 h-1 overflow-hidden rounded-b-xl bg-slate-200 dark:bg-slate-800"
>
<div
class="h-full rounded-full bg-gradient-to-r from-brand to-brand-dark"
:style="progressStyle"
></div>
</div> </div>
</label> </label>
</template> </template>
@@ -111,6 +162,8 @@ const props = defineProps<{
item: UpdateCenterItem; item: UpdateCenterItem;
task?: UpdateCenterTaskState; task?: UpdateCenterTaskState;
selected: boolean; selected: boolean;
// held
forced?: boolean;
}>(); }>();
const PLACEHOLDER_ICON = const PLACEHOLDER_ICON =
@@ -121,8 +174,15 @@ defineEmits<{
(e: "toggle-selection"): void; (e: "toggle-selection"): void;
(e: "ignore-item"): void; (e: "ignore-item"): void;
(e: "unignore-item"): void; (e: "unignore-item"): void;
(e: "toggle-force"): void;
}>(); }>();
//
const isSelectionLocked = computed(
() =>
props.item.ignored === true || (props.item.held === true && !props.forced),
);
const normalizeIconSrc = (icon: string): string => { const normalizeIconSrc = (icon: string): string => {
if (/^[a-z]+:\/\//i.test(icon)) { if (/^[a-z]+:\/\//i.test(icon)) {
return icon; return icon;
@@ -185,4 +245,27 @@ const showProgress = computed(() => {
const progressText = computed(() => `${props.task?.progress ?? 0}%`); const progressText = computed(() => `${props.task?.progress ?? 0}%`);
const progressStyle = computed(() => ({ width: progressText.value })); const progressStyle = computed(() => ({ width: progressText.value }));
// 3
const timeLabel = computed(() => {
const t = props.item.updateTime;
if (!t || typeof t !== "number") return "—";
const diff = Date.now() - t;
const day = 24 * 60 * 60 * 1000;
if (diff < 0) return "刚刚";
if (diff < day) return "今天";
if (diff < 2 * day) return "昨天";
if (diff < 30 * day) return `${Math.floor(diff / day)}天前`;
if (diff < 365 * day) return `${Math.floor(diff / (30 * day))}个月前`;
return `${Math.floor(diff / (365 * day))}年前`;
});
// 45 MB / 1.2 GB
const sizeLabel = computed(() => {
const size = props.item.size;
if (!size || size <= 0) return "—";
const mb = size / (1024 * 1024);
if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`;
return `${Math.round(mb)} MB`;
});
</script> </script>
@@ -10,12 +10,14 @@
</div> </div>
<div v-else class="space-y-3"> <div v-else class="space-y-3">
<UpdateCenterItem <UpdateCenterItem
v-for="item in items" v-for="item in enrichedItems"
:key="item.taskKey" :key="item.taskKey"
:item="item" :item="item"
:task="taskMap.get(item.taskKey)" :task="taskMap.get(item.taskKey)"
:selected="selectedTaskKeys.has(item.taskKey)" :selected="selectedTaskKeys.has(item.taskKey)"
:forced="forcedTaskKeys.has(item.taskKey)"
@toggle-selection="$emit('toggle-selection', item.taskKey)" @toggle-selection="$emit('toggle-selection', item.taskKey)"
@toggle-force="$emit('toggle-force', item.taskKey)"
@ignore-item="$emit('ignore-item', item.packageName, item.newVersion)" @ignore-item="$emit('ignore-item', item.packageName, item.newVersion)"
@unignore-item=" @unignore-item="
$emit('unignore-item', item.packageName, item.newVersion) $emit('unignore-item', item.packageName, item.newVersion)
@@ -29,6 +31,7 @@
import { computed } from "vue"; import { computed } from "vue";
import type { import type {
App,
UpdateCenterItem as UpdateCenterItemModel, UpdateCenterItem as UpdateCenterItemModel,
UpdateCenterTaskState, UpdateCenterTaskState,
} from "@/global/typedefinition"; } from "@/global/typedefinition";
@@ -39,14 +42,34 @@ const props = defineProps<{
items: UpdateCenterItemModel[]; items: UpdateCenterItemModel[];
tasks: UpdateCenterTaskState[]; tasks: UpdateCenterTaskState[];
selectedTaskKeys: Set<string>; selectedTaskKeys: Set<string>;
forcedTaskKeys: Set<string>;
apps: App[];
}>(); }>();
defineEmits<{ defineEmits<{
(e: "toggle-selection", taskKey: string): void; (e: "toggle-selection", taskKey: string): void;
(e: "toggle-force", taskKey: string): void;
(e: "ignore-item", packageName: string, newVersion: string): void; (e: "ignore-item", packageName: string, newVersion: string): void;
(e: "unignore-item", packageName: string, newVersion: string): void; (e: "unignore-item", packageName: string, newVersion: string): void;
}>(); }>();
// apps update app.json Update "2026-08-08"
// X
const enrichedItems = computed<UpdateCenterItemModel[]>(() => {
const updateByPkg = new Map<string, number>();
for (const app of props.apps) {
if (app.pkgname && app.update) {
const t = Date.parse(app.update);
if (!Number.isNaN(t)) updateByPkg.set(app.pkgname, t);
}
}
return props.items.map((item) => {
if (item.updateTime && !Number.isNaN(item.updateTime)) return item;
const t = updateByPkg.get(item.packageName);
return t ? { ...item, updateTime: t } : item;
});
});
const taskMap = computed(() => { const taskMap = computed(() => {
return new Map(props.tasks.map((task) => [task.taskKey, task])); return new Map(props.tasks.map((task) => [task.taskKey, task]));
}); });
@@ -11,7 +11,7 @@
集中管理 APM 与传统 deb 更新任务 集中管理 APM 与传统 deb 更新任务
</p> </p>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex flex-wrap items-center justify-end gap-2">
<button <button
type="button" type="button"
class="inline-flex items-center gap-2 rounded-2xl border border-slate-200/70 px-4 py-2 text-sm font-semibold text-slate-600 transition hover:bg-slate-50 disabled:opacity-40 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800" class="inline-flex items-center gap-2 rounded-2xl border border-slate-200/70 px-4 py-2 text-sm font-semibold text-slate-600 transition hover:bg-slate-50 disabled:opacity-40 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
@@ -21,6 +21,37 @@
<i class="fas fa-sync-alt" :class="{ 'animate-spin': loading }"></i> <i class="fas fa-sync-alt" :class="{ 'animate-spin': loading }"></i>
{{ loading ? "刷新中" : "刷新" }} {{ loading ? "刷新中" : "刷新" }}
</button> </button>
<label
class="inline-flex cursor-pointer items-center gap-2 select-none rounded-2xl border border-slate-200/70 px-3 py-2 transition hover:bg-slate-50 dark:border-slate-700 dark:hover:bg-slate-800"
:class="{
'cursor-not-allowed opacity-50 hover:bg-transparent dark:hover:bg-transparent':
selectableCount === 0,
}"
:title="
selectableCount === 0
? '当前没有可更新项(全部被锁定未强制或已忽略)'
: '全选所有可更新项'
"
>
<input
ref="selectAllRef"
type="checkbox"
class="h-4 w-4 rounded border-slate-300 accent-brand focus:ring-brand disabled:cursor-not-allowed"
:checked="allSelected"
:disabled="selectableCount === 0"
@change="$emit('toggle-select-all')"
/>
<span class="text-sm font-medium text-slate-700 dark:text-slate-200"
>全选</span
>
</label>
<span
class="text-sm text-slate-400 dark:text-slate-500"
:title="selectableCount === 0 ? '无可更新项' : '已选中的更新项数量'"
>
<template v-if="selectableCount === 0">无可更新项</template>
<template v-else>已选 {{ selectedCount }} </template>
</span>
<button <button
type="button" type="button"
class="inline-flex items-center gap-2 rounded-2xl bg-gradient-to-r from-brand to-brand-dark px-4 py-2 text-sm font-semibold text-white shadow-lg disabled:opacity-40" class="inline-flex items-center gap-2 rounded-2xl bg-gradient-to-r from-brand to-brand-dark px-4 py-2 text-sm font-semibold text-white shadow-lg disabled:opacity-40"
@@ -41,31 +72,15 @@
</div> </div>
</div> </div>
<div class="flex items-center gap-3">
<label class="inline-flex cursor-pointer items-center gap-2 select-none">
<input
ref="selectAllRef"
type="checkbox"
class="h-4 w-4 rounded border-slate-300 accent-brand focus:ring-brand"
:checked="allSelected"
@change="$emit('toggle-select-all')"
/>
<span class="text-sm font-medium text-slate-700 dark:text-slate-200"
>全选</span
>
</label>
<span class="text-sm text-slate-400 dark:text-slate-500">
已选 {{ selectedCount }}
</span>
</div>
<label class="block relative"> <label class="block relative">
<span class="sr-only">搜索更新</span> <span class="sr-only">搜索更新</span>
<i
class="fas fa-search pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-slate-400"
></i>
<input <input
:value="searchQuery" :value="searchQuery"
type="search"
placeholder="搜索应用或包名" placeholder="搜索应用或包名"
class="w-full rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-brand/60 focus:bg-white dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100 dark:focus:bg-slate-900" class="w-full rounded-2xl border border-slate-200/70 bg-white/80 py-3 pl-12 pr-20 text-sm text-slate-700 shadow-sm outline-none transition placeholder:text-slate-400 focus:border-brand/50 focus:ring-4 focus:ring-brand/10 dark:border-slate-800/70 dark:bg-slate-900/60 dark:text-slate-200"
@input="handleInput" @input="handleInput"
/> />
<button <button
@@ -87,6 +102,7 @@ import { ref, watch } from "vue";
const props = defineProps<{ const props = defineProps<{
searchQuery: string; searchQuery: string;
selectedCount: number; selectedCount: number;
selectableCount: number;
allSelected: boolean; allSelected: boolean;
someSelected: boolean; someSelected: boolean;
loading?: boolean; loading?: boolean;
+437
View File
@@ -0,0 +1,437 @@
/**
* useAccountSync /
*
* App.vue requireLogin / openLoginFromPrompt / handleFlarumLogin /
* loadDownloadedHistory / refreshInstalledSyncCandidates / syncInstalledAppsToAccount /
* syncInstalledAppsNow / openRestoreFromAccount / installCloudItems /
* maybePromptInstalledSync / openUserManagement / handleLogout / openReviewUserProfile /
* / clear*State
*
* useAppStatecurrentUser / isLoggedIn authState
* clearFavoriteState useFavoritesonDetailInstall useDownloads
*/
import type {
FlarumLoginPayload,
SyncedAppListItem,
AppReview,
App,
} from "../global/typedefinition";
import {
downloadedApps,
downloadedLoading,
downloadedError,
downloadedRequestGeneration,
syncLoading,
syncStatusMessage,
syncRequestGeneration,
syncCandidateApps,
restoreLoading,
restoreError,
showRestoreModal,
restoreItems,
restoreRequestGeneration,
installedSyncPromptShown,
systemInfo,
showUserManagementModal,
isSidebarOpen,
showLoginPrompt,
showLoginModal,
currentView,
activeTab,
selectedCategory,
selectedReviewUserProfile,
showReviewUserProfileModal,
sparkAvailable,
apmAvailable,
storeFilter,
apps,
showLoginPromptMessage,
loginLoading,
loginError,
} from "./useAppState";
import { isLoggedIn, currentUser } from "../global/authState";
import {
setInstalledSyncEnabled,
loadInstalledSyncPreference,
installedSyncEnabled,
} from "../global/accountSyncState";
import {
exchangeFlarumToken,
listDownloadedApps,
uploadSyncedAppList,
fetchSyncedAppList,
} from "../modules/backendApi";
import { requestFlarumToken } from "../modules/flarumAuth";
import {
buildSyncItems,
mergeInstalledApps,
resolveCloudInstallCandidate,
} from "../modules/appListSync";
import { isOriginEnabled } from "../modules/storeFilter";
import { clearFavoriteState } from "./useFavorites";
import { onDetailInstall } from "./useDownloads";
import { registerRequireLogin } from "./useAppDetail";
import { registerRequireLogin as registerRequireLoginFav } from "./useFavorites";
// requireLogin 供 useAppDetail / useFavorites / 本模块内部使用;登记到其它 composable
export const requireLogin = (message: string): boolean => {
if (isLoggedIn.value) return true;
showLoginPromptMessage.value = message;
showLoginPrompt.value = true;
return false;
};
registerRequireLogin(requireLogin);
registerRequireLoginFav(requireLogin);
const openLoginFromPrompt = () => {
showLoginPrompt.value = false;
showLoginModal.value = true;
};
const clearDownloadedState = () => {
downloadedRequestGeneration.value += 1;
downloadedApps.value = [];
downloadedLoading.value = false;
downloadedError.value = "";
};
const clearRestoreState = () => {
restoreRequestGeneration.value += 1;
restoreItems.value = [];
restoreLoading.value = false;
restoreError.value = "";
showRestoreModal.value = false;
};
const clearInstalledSyncState = () => {
syncRequestGeneration.value += 1;
syncLoading.value = false;
syncStatusMessage.value = "";
syncCandidateApps.value = [];
};
const nextDownloadedRequestGeneration = (): number => {
downloadedRequestGeneration.value += 1;
return downloadedRequestGeneration.value;
};
const isCurrentDownloadedRequest = (
generation: number,
userId: number,
): boolean =>
downloadedRequestGeneration.value === generation &&
currentUser.value?.id === userId;
const isCurrentRestoreRequest = (generation: number, userId: number): boolean =>
restoreRequestGeneration.value === generation &&
currentUser.value?.id === userId;
const handleLogout = () => {
// 调用 authState 的 logout(此处通过注入,保持 authState 为唯一会话源)
logoutRef();
pendingDownloadRecordsClear();
clearFavoriteState();
clearDownloadedState();
clearRestoreState();
clearInstalledSyncState();
loadInstalledSyncPreference(null);
showLoginModal.value = false;
showLoginPrompt.value = false;
isSidebarOpen.value = false;
showUserManagementModal.value = false;
if (currentView.value === "favorites") {
currentView.value = "default";
activeTab.value = "home";
selectedCategory.value = "all";
}
};
const handleFlarumLogin = async (payload: FlarumLoginPayload) => {
loginLoading.value = true;
loginError.value = "";
try {
const flarumToken = await requestFlarumToken(payload);
const session = await exchangeFlarumToken({
flarumUserId: flarumToken.userId,
flarumToken: flarumToken.token,
});
setAuthSessionRef(session);
clearInstalledSyncState();
loadInstalledSyncPreference(session.user.id);
showLoginModal.value = false;
} catch (error: unknown) {
loginError.value = (error as Error)?.message || "登录失败,请稍后重试";
} finally {
loginLoading.value = false;
}
};
const loadDownloadedHistory = async (): Promise<void> => {
if (!requireLogin("请登录后查看和管理账号信息。")) return;
const userId = currentUser.value?.id;
if (userId === undefined) return;
const generation = nextDownloadedRequestGeneration();
downloadedLoading.value = true;
downloadedError.value = "";
try {
const result = await listDownloadedApps(1, 50);
if (!isCurrentDownloadedRequest(generation, userId)) return;
downloadedApps.value = result.items;
} catch (error: unknown) {
if (!isCurrentDownloadedRequest(generation, userId)) return;
downloadedApps.value = [];
downloadedError.value = (error as Error)?.message || "读取下载历史失败";
} finally {
if (isCurrentDownloadedRequest(generation, userId)) {
downloadedLoading.value = false;
}
}
};
const refreshInstalledSyncCandidates = async (
isCurrentRequest: () => boolean,
): Promise<boolean> => {
const origins: Array<"spark" | "apm"> = [];
if (isOriginEnabled(storeFilter.value, "spark") && sparkAvailable.value) {
origins.push("spark");
}
if (isOriginEnabled(storeFilter.value, "apm") && apmAvailable.value) {
origins.push("apm");
}
const refreshedApps: App[] = [];
await Promise.all(
origins.map(async (origin) => {
const pkgnameList =
origin === "spark"
? apps.value
.filter((app) => app.origin === "spark")
.map((app) => app.pkgname)
: undefined;
const result = await window.ipcRenderer.invoke("list-installed", {
origin,
pkgnameList,
});
if (!result?.success) return;
const appList = Array.isArray(result?.apps) ? result.apps : [];
for (const rawApp of appList) {
// 运行时类型守卫:避免后端字段缺失时下游访问 undefined
if (!isInstalledAppInfoRef(rawApp)) continue;
const appInfo = mapInstalledAppToCatalogAppRef(rawApp, origin);
if (appInfo) refreshedApps.push(appInfo);
}
}),
);
if (!isCurrentRequest()) {
return false;
}
syncCandidateApps.value = mergeInstalledApps(
syncCandidateApps.value,
refreshedApps,
origins,
);
return true;
};
const syncInstalledAppsToAccount = async (): Promise<void> => {
if (!requireLogin("云端同步需要登录星火账号。")) return;
if (syncLoading.value) return;
const userId = currentUser.value?.id;
if (userId === undefined) return;
const generation = syncRequestGeneration.value + 1;
syncRequestGeneration.value = generation;
syncLoading.value = true;
syncStatusMessage.value = "";
try {
const refreshed = await refreshInstalledSyncCandidates(
() =>
syncRequestGeneration.value === generation &&
currentUser.value?.id === userId,
);
if (!refreshed) return;
const items = buildSyncItems(syncCandidateApps.value);
await uploadSyncedAppList({
clientArch: window.apm_store.arch || "amd64",
distro: systemInfo.value.distro,
items,
});
if (
syncRequestGeneration.value !== generation ||
currentUser.value?.id !== userId
) {
return;
}
downloadedError.value = "";
syncStatusMessage.value = "同步完成";
} catch (error: unknown) {
if (
syncRequestGeneration.value !== generation ||
currentUser.value?.id !== userId
) {
return;
}
downloadedError.value = (error as Error)?.message || "同步已安装应用失败";
syncStatusMessage.value = downloadedError.value;
} finally {
if (
syncRequestGeneration.value === generation &&
currentUser.value?.id === userId
) {
syncLoading.value = false;
}
}
};
const syncInstalledAppsNow = (): void => {
void syncInstalledAppsToAccount();
};
const openRestoreFromAccount = async (): Promise<void> => {
if (!requireLogin("云端同步需要登录星火账号。")) return;
const userId = currentUser.value?.id;
if (userId === undefined) return;
const generation = restoreRequestGeneration.value + 1;
restoreRequestGeneration.value = generation;
showRestoreModal.value = true;
restoreLoading.value = true;
restoreError.value = "";
restoreItems.value = [];
try {
const refreshed = await refreshInstalledSyncCandidates(() =>
isCurrentRestoreRequest(generation, userId),
);
if (!refreshed) return;
const result = await fetchSyncedAppList();
if (!isCurrentRestoreRequest(generation, userId)) return;
restoreItems.value = result?.items || [];
} catch (error: unknown) {
if (!isCurrentRestoreRequest(generation, userId)) return;
restoreError.value = (error as Error)?.message || "读取云端应用列表失败";
} finally {
if (isCurrentRestoreRequest(generation, userId)) {
restoreLoading.value = false;
}
}
};
// 批量云端安装:分批触发(每批 3 个),收集每个安装的结果并反馈失败项。
// onDetailInstall 内部本身已串行排队,这里仅限制"同时发起"的并发,避免一次注入大量任务。
const installCloudItems = async (items: SyncedAppListItem[]): Promise<void> => {
const BATCH_SIZE = 3;
let failedCount = 0;
for (let i = 0; i < items.length; i += BATCH_SIZE) {
const batch = items.slice(i, i + BATCH_SIZE);
const results = await Promise.allSettled(
batch.map(async (item) => {
const app = resolveCloudInstallCandidate(item, apps.value);
if (!app) return;
await onDetailInstall(app);
}),
);
failedCount += results.filter((r) => r.status === "rejected").length;
}
if (failedCount > 0) {
console.error(`批量云端安装中有 ${failedCount} 项失败`);
}
showRestoreModal.value = false;
};
const maybePromptInstalledSync = async (): Promise<void> => {
if (
import.meta.env.MODE === "test" ||
!isLoggedIn.value ||
installedSyncPromptShown.value ||
installedSyncEnabled.value !== null
) {
if (isLoggedIn.value && installedSyncEnabled.value === true) {
await syncInstalledAppsToAccount();
}
return;
}
installedSyncPromptShown.value = true;
const enabled = window.confirm(
"是否启用已安装应用列表自动同步到星火账号?仅同步商店识别的非依赖应用。",
);
setInstalledSyncEnabled(enabled);
if (enabled) await syncInstalledAppsToAccount();
};
const openUserManagement = async () => {
if (!requireLogin("请登录后查看和管理账号信息。")) return;
showUserManagementModal.value = true;
isSidebarOpen.value = false;
showLoginPrompt.value = false;
await loadDownloadedHistory();
};
const openReviewUserProfile = (review: AppReview): void => {
const current = currentUser.value;
const isCurrentUser =
review.isAuthor === true ||
(review.userId !== undefined &&
current?.id !== undefined &&
review.userId === current.id);
selectedReviewUserProfile.value = {
displayName: review.userDisplayName || "星火用户",
username: isCurrentUser ? current?.username : undefined,
avatarUrl:
review.userAvatarUrl || (isCurrentUser ? current?.avatarUrl : undefined),
coverUrl: isCurrentUser ? current?.coverUrl : undefined,
forumGroups: isCurrentUser ? current?.forumGroups : undefined,
};
showReviewUserProfileModal.value = true;
};
// 以下引用通过注入方式从 authState / useInstalledApps 获取,避免循环依赖:
// logout / setAuthSessionauthState)、isInstalledAppInfo / mapInstalledAppToCatalogApp
// useInstalledApps)、pendingDownloadRecords.clearuseDownloads
let logoutRef: () => void = () => undefined;
export const registerLogout = (fn: () => void) => {
logoutRef = fn;
};
let setAuthSessionRef: (session: unknown) => void = () => undefined;
export const registerSetAuthSession = (fn: (session: unknown) => void) => {
setAuthSessionRef = fn;
};
let isInstalledAppInfoRef: (value: unknown) => boolean = () => false;
export const registerIsInstalledAppInfo = (fn: (value: unknown) => boolean) => {
isInstalledAppInfoRef = fn;
};
let mapInstalledAppToCatalogAppRef: (
app: unknown,
origin: "spark" | "apm",
) => App | null = () => null;
export const registerMapInstalledAppToCatalogApp = (
fn: (app: unknown, origin: "spark" | "apm") => App | null,
) => {
mapInstalledAppToCatalogAppRef = fn;
};
let pendingDownloadRecordsClear: () => void = () => undefined;
export const registerPendingDownloadRecordsClear = (fn: () => void) => {
pendingDownloadRecordsClear = fn;
};
export {
openLoginFromPrompt,
handleLogout,
handleFlarumLogin,
loadDownloadedHistory,
refreshInstalledSyncCandidates,
syncInstalledAppsToAccount,
syncInstalledAppsNow,
openRestoreFromAccount,
installCloudItems,
maybePromptInstalledSync,
openUserManagement,
openReviewUserProfile,
clearDownloadedState,
clearRestoreState,
clearInstalledSyncState,
};
+552
View File
@@ -0,0 +1,552 @@
/**
* useAppDetail /
*
* App.vue fetchAppFromStore / openDetail / openDetailFromInstalled /
* createFallbackApp / checkAppInstalled / loadScreenshots / closeDetail /
* openScreenPreview / closeScreenPreview / prevScreen / nextScreen /
* selectDetailOrigin / handleDetailRequestLogin / currentDisplayApp /
* currentReviewAppKey / currentReviewTags
*
* useAppStateloadFavoriteMetadataForDetail useFavorites
*/
import { computed, nextTick } from "vue";
import type { App } from "../global/typedefinition";
import {
apps,
currentApp,
currentAppSparkInstalled,
currentAppApmInstalled,
showModal,
showPreview,
currentScreenIndex,
screenshots,
storeFilter,
favoriteFolders,
favoriteLoading,
systemInfo,
} from "./useAppState";
import { isLoggedIn } from "../global/authState";
import { APM_STORE_BASE_URL } from "../global/storeConfig";
import { getHybridDefaultOrigin } from "../global/storeConfig";
import {
getDisplayApp,
buildReviewAppKey,
buildReviewTags,
} from "../modules/appIdentity";
import { loadFavoriteMetadataForDetail } from "./useFavorites";
import { axiosInstance } from "./useHttp";
import type { ReviewTags } from "../global/typedefinition";
import type { Ref } from "vue";
const clientArch = computed(() => window.apm_store.arch || "amd64");
const currentDisplayApp = computed(() => getDisplayApp(currentApp.value));
const currentReviewAppKey = computed(() => {
if (!currentDisplayApp.value) return "";
return buildReviewAppKey(currentDisplayApp.value, clientArch.value);
});
const currentReviewTags = computed<ReviewTags | null>(() => {
if (!currentDisplayApp.value) return null;
return buildReviewTags(currentDisplayApp.value, {
clientArch: clientArch.value,
distro: systemInfo.value.distro,
});
});
// 截图探测请求的中断控制器:每次打开新详情前取消旧探测,避免结果覆盖当前应用
let screenshotAbortController: AbortController | null = null;
// 从仓库获取应用详细信息的辅助函数
const fetchAppFromStore = async (
pkgname: string,
category: string,
origin: "spark" | "apm",
): Promise<App | null> => {
try {
const arch = window.apm_store.arch || "amd64";
const finalArch = origin === "spark" ? `${arch}-store` : `${arch}-apm`;
// 路径参数需编码,避免特殊字符破坏请求路径或造成路径穿越
const appJsonUrl = `${APM_STORE_BASE_URL}/${finalArch}/${encodeURIComponent(
category,
)}/${encodeURIComponent(pkgname)}/app.json`;
// 接入 rootAbortController.signal,确保组件卸载/详情关闭时请求可被取消,
// 避免竞态与内存泄漏(onUnmounted 会 abort 该 controller
const response = await fetch(appJsonUrl, {
signal: (await import("./useHttp")).rootAbortController.signal,
});
if (!response.ok) return null;
const appJson = await response.json();
// img_urls 可能为字符串形式的 JSON,解析失败时安全回退为空数组
const parsedImgUrls = (() => {
if (typeof appJson.img_urls === "string") {
try {
return JSON.parse(appJson.img_urls) as string[];
} catch {
return [];
}
}
return (appJson.img_urls as string[]) || [];
})();
return {
name: appJson.Name || "",
pkgname: appJson.Pkgname || pkgname,
version: appJson.Version || "",
filename: appJson.Filename || "",
torrent_address: appJson.Torrent_address || "",
author: appJson.Author || "",
contributor: appJson.Contributor || "",
website: appJson.Website || "",
update: appJson.Update || "",
size: appJson.Size || "",
more: appJson.More || "",
tags: appJson.Tags || "",
img_urls: parsedImgUrls,
icons: appJson.icons || "",
category,
origin,
currentStatus: "not-installed",
};
} catch (e) {
// 组件卸载/详情关闭触发 abort 时静默返回,避免刷 AbortError 日志
if ((e as Error)?.name === "AbortError") return null;
console.warn(`Failed to fetch ${origin} app info for ${pkgname}`, e);
return null;
}
};
// 已安装应用页查看详情:复用所有应用页的合并详情视图(始终展示 APM/Spark 两种包)
const openDetailFromInstalled = (app: App) => {
openDetail({ ...app, _fromInstalled: true });
};
// openDetail 输入类型:完整 App 或仅含必要字段的轻量对象(含内部来源标记)
interface OpenDetailInput extends Partial<App> {
pkgname?: string;
category?: string;
_fromHomeView?: boolean;
_fromInstalled?: boolean;
_fromDeepLink?: boolean;
origin?: "spark" | "apm";
}
// 提取远程/本地均无匹配时的回退 App 构造(两处兜底分支共用,避免重复字段映射)
const createFallbackApp = (
raw: Record<string, unknown>,
pkgname: string,
category: string,
): App => ({
name: (raw.name as string) || "",
pkgname,
version: (raw.version as string) || "",
filename: (raw.filename as string) || "",
category,
torrent_address: "",
author: "",
contributor: "",
website: "",
update: "",
size: "",
more: (raw.more as string) || "",
tags: "",
img_urls: [],
icons: "",
origin: (raw.origin as "spark" | "apm") || "apm",
currentStatus: "not-installed",
});
const openDetail = async (app: App | OpenDetailInput) => {
// 提取 pkgname 和 category(必须存在)
const pkgname = app?.pkgname;
if (!pkgname) {
console.warn("openDetail called without pkgname");
return;
}
const category = app.category || "unknown";
// 检查是否来自 HomeView 或 DeepLink(需要重新获取完整信息)
// 内部来源标记仅存在于轻量输入对象上,按 OpenDetailInput 读取以兼容联合类型
const marker = app as OpenDetailInput;
const fromHomeView = marker._fromHomeView === true;
const fromDeepLink = marker._fromDeepLink === true;
// 已安装应用页:始终按"所有应用页"的方式双来源拉取并合并展示(不移除另一类型)
const fromInstalled = marker._fromInstalled === true;
const needFetchFromStore = fromHomeView || fromDeepLink || fromInstalled;
// 首先尝试从当前已经处理好(合并/筛选)的 filteredApps 中查找
// 优先匹配点击来源 origin(例如排行点击 APM 应用时不应误匹配到 Spark 版)
const clickedOrigin = app.origin as "spark" | "apm" | undefined;
let fullApp = filteredAppsFind(pkgname, clickedOrigin);
// 如果没找到,回退到全局 apps 中查找(同样优先 origin
if (!fullApp) {
fullApp = apps.value.find(
(a) =>
a.pkgname === pkgname && (!clickedOrigin || a.origin === clickedOrigin),
);
}
// 仍无匹配则退化为仅按 pkgname 匹配(兼容无 origin 的场景,如搜索结果)
if (!fullApp) {
fullApp =
filteredAppsFind(pkgname, undefined) ||
apps.value.find((a) => a.pkgname === pkgname);
}
let finalApp: App;
// 来自 HomeView 或 DeepLink 的应用需要重新从仓库获取完整信息
if (needFetchFromStore) {
// 从 Spark 和 APM 仓库获取完整的应用信息
// 已安装页忽略当前商店单一模式限制,始终尝试拉取两种来源,保证另一类型不被隐藏
let [sparkApp, apmApp] = await Promise.all([
fromInstalled || storeFilter.value !== "apm"
? fetchAppFromStore(pkgname, category, "spark")
: Promise.resolve(null),
fromInstalled || storeFilter.value !== "spark"
? fetchAppFromStore(pkgname, category, "apm")
: Promise.resolve(null),
]);
// 已安装页:若某来源仓库拉取失败(如分类不匹配),用本地已合并的应用补全,避免丢失另一类型
if (fromInstalled && fullApp && fullApp.isMerged) {
const merged = fullApp as App;
if (!sparkApp && merged.sparkApp) sparkApp = merged.sparkApp;
if (!apmApp && merged.apmApp) apmApp = merged.apmApp;
}
// 构建合并的应用对象
if (sparkApp || apmApp) {
// 如果两个仓库都有这个应用,创建合并对象
if (sparkApp && apmApp) {
// 优先遵从点击来源 origin;无点击来源时再按优先级配置决定默认显示
const defaultOrigin =
clickedOrigin && (clickedOrigin === "spark" ? sparkApp : apmApp)
? clickedOrigin
: getHybridDefaultOrigin(sparkApp);
finalApp = {
...(defaultOrigin === "spark" ? sparkApp : apmApp), // 根据优先级选择主显示
isMerged: true,
sparkApp: sparkApp,
apmApp: apmApp,
viewingOrigin: defaultOrigin, // 默认查看来源版本
};
} else if (sparkApp) {
finalApp = sparkApp;
} else {
finalApp = apmApp!;
}
} else if (fullApp) {
finalApp = fullApp;
} else {
// 两个仓库都没有找到,且本地也没有,构造一个最小可用的 App 对象
finalApp = createFallbackApp(
app as Record<string, unknown>,
pkgname,
category,
);
}
} else {
// 非 HomeView 来源,使用原来的逻辑
if (fullApp) {
finalApp = fullApp;
} else {
// 构造一个最小可用的 App 对象
finalApp = createFallbackApp(
app as Record<string, unknown>,
pkgname,
category,
);
}
}
// 检查 Spark/APM 安装状态,已安装的版本优先展示
let installedStateKnown = false;
if (finalApp.isMerged && (finalApp.sparkApp || finalApp.apmApp)) {
const [sparkInstalled, apmInstalled] = await Promise.all([
finalApp.sparkApp
? (window.ipcRenderer.invoke("check-installed", {
pkgname: finalApp.sparkApp.pkgname,
origin: "spark",
}) as Promise<boolean>)
: Promise.resolve(false),
finalApp.apmApp
? (window.ipcRenderer.invoke("check-installed", {
pkgname: finalApp.apmApp.pkgname,
origin: "apm",
}) as Promise<boolean>)
: Promise.resolve(false),
]);
installedStateKnown = true;
// 来源默认展示规则:
// 1) 已安装页打开:按安装类型打开。
// - 仅一个来源已安装 → 强制展示该已装来源(无需策略覆盖)
// - 多个来源已安装 → 按「设置的优先标签/应用优先级」打开(策略)
// 2) 其他页面:
// - 仅一个来源已安装 → 默认展示已装版本(详情页按安装状态优先,保证可直接打开)
// - 都安装/都未安装 → 按「标签优先显示策略/应用优先级」打开
// 注:forceViewingOrigin 仅用于「已安装页 + 唯一安装来源」这一显式安装类型场景,
// 其余情况都不强制,交由详情页按安装状态/用户标签策略重算。
let forceOrigin: "spark" | "apm" | undefined = undefined;
if (fromInstalled) {
const installedOrigins = (app as Record<string, unknown>).origins as
| Array<"spark" | "apm">
| undefined;
if (installedOrigins && installedOrigins.length === 1) {
// 单来源安装:按安装类型强制
forceOrigin = installedOrigins[0];
} else if (installedOrigins && installedOrigins.length > 1) {
// 多来源安装:交由策略(按设置优先标签),不强制
forceOrigin = undefined;
} else {
// origins 未随事件携带时,用 IPC 检测结果兜底判断安装类型
if (sparkInstalled && !apmInstalled) forceOrigin = "spark";
else if (apmInstalled && !sparkInstalled) forceOrigin = "apm";
}
}
// 非强制分支:清除可能由「已安装页」入口遗留的 forceViewingOrigin 粘性标志,
// 避免同一应用从其他页面再次打开时被锁死在旧来源(表现为设置切换不生效)。
finalApp.forceViewingOrigin = false;
if (
forceOrigin &&
(forceOrigin === "spark" ? finalApp.sparkApp : finalApp.apmApp)
) {
// 已安装页 + 唯一安装来源 → 强制展示该安装类型,优先级高于用户标签策略
finalApp.viewingOrigin = forceOrigin;
finalApp.forceViewingOrigin = true;
} else if (sparkInstalled && !apmInstalled) {
// 仅 Spark 安装(其他页面):默认回退展示已装版本,不强制(详情页会按安装状态优先)
finalApp.viewingOrigin = "spark";
} else if (apmInstalled && !sparkInstalled) {
finalApp.viewingOrigin = "apm";
} else {
// 都安装/都未安装且未指定来源:交由「标签优先显示策略」决定默认展示。
// 此处仅写入混合默认作为回退(供 appIdentity / 截图等下游使用),
// 但不置 forceViewingOrigin,详情页会按用户策略重算。
finalApp.viewingOrigin = getHybridDefaultOrigin(
finalApp.sparkApp || finalApp,
);
}
// 已拿到安装状态时直接写入全局状态,避免详情页先按“都未安装”闪一下再切到已装版本
currentAppSparkInstalled.value = sparkInstalled;
currentAppApmInstalled.value = apmInstalled;
}
const displayAppForScreenshots =
finalApp.viewingOrigin !== undefined && finalApp.isMerged
? ((finalApp.viewingOrigin === "spark"
? finalApp.sparkApp
: finalApp.apmApp) ?? finalApp)
: finalApp;
currentApp.value = finalApp;
currentScreenIndex.value = 0;
// 先清空截图,避免详情页渲染时残留上一个应用的图片(新图需异步探测后才填充)。
screenshots.value = [];
// 截图按固定路径拼接 screen_1~5.png 并探测真实存在性;异步进行,详情页先打开不阻塞。
// 每次打开取消上一轮探测,避免陈旧结果覆盖当前应用。
if (screenshotAbortController) screenshotAbortController.abort();
screenshotAbortController = new AbortController();
loadScreenshots(
displayAppForScreenshots,
screenshotAbortController.signal,
).catch(() => {
// 被取消或异常已在 loadScreenshots 内处理
});
showModal.value = true;
if (!installedStateKnown) {
currentAppSparkInstalled.value = false;
currentAppApmInstalled.value = false;
checkAppInstalled(finalApp);
}
if (
isLoggedIn.value &&
favoriteFolders.value.length === 0 &&
!favoriteLoading.value
) {
void loadFavoriteMetadataForDetail();
}
nextTick(() => {
const modal = document.querySelector(
'[data-app-modal="detail"] [data-testid="detail-scroll-content"]',
);
if (modal) (modal as HTMLElement).scrollTop = 0;
});
};
const checkAppInstalled = (app: App) => {
if (app.isMerged) {
if (app.sparkApp) {
window.ipcRenderer
.invoke("check-installed", {
pkgname: app.sparkApp.pkgname,
origin: "spark",
})
.then((isInstalled: boolean) => {
currentAppSparkInstalled.value = isInstalled;
});
}
if (app.apmApp) {
window.ipcRenderer
.invoke("check-installed", {
pkgname: app.apmApp.pkgname,
origin: "apm",
})
.then((isInstalled: boolean) => {
currentAppApmInstalled.value = isInstalled;
});
}
} else {
window.ipcRenderer
.invoke("check-installed", { pkgname: app.pkgname, origin: app.origin })
.then((isInstalled: boolean) => {
if (app.origin === "spark") {
currentAppSparkInstalled.value = isInstalled;
} else {
currentAppApmInstalled.value = isInstalled;
}
});
}
};
// 截图来源直接采用后端 app.img_urls(权威真实列表,微信 3 张即返回 3 个 URL)。
// 无需前端再 HEAD 探测 screen_1~5.png —— 既消除每次打开详情的额外请求压力,
// 又保证"实际有几张就预览几张",边界由 ScreenPreview 的 length 自动收敛。
// 截图 URL 存在性缓存:按 URL 记忆探测结果(true=存在 / false=404 等无效),
// 避免同一应用反复打开详情时重复发起 HEAD 探测,降低服务器压力。
const screenshotExistenceCache = new Map<string, boolean>();
// 探测单张截图是否真实存在:HEAD 请求,超时 3s;命中缓存直接返回。
const checkScreenshotExists = async (
url: string,
signal?: AbortSignal,
): Promise<boolean> => {
const cached = screenshotExistenceCache.get(url);
if (cached !== undefined) return cached;
try {
await axiosInstance.head(url, { timeout: 3000, signal });
screenshotExistenceCache.set(url, true);
return true;
} catch (err) {
// 请求被主动取消(用户快速切换/关闭详情触发 abort)时,不应把该 URL 误记为
// "不存在"污染缓存——否则同进程内再打开该应用会少显示图片,直到重启才恢复。
// 取消错误直接上抛,由 loadScreenshots 的 Promise.all reject 被 openDetail 吞掉,
// 既不写缓存也不覆盖 screenshots,后续成功的轮次(新 controller)会正确填充。
if (signal?.aborted) throw err;
screenshotExistenceCache.set(url, false);
return false;
}
};
// 截图候选 URL 一律按固定规则前端拼接,最多 5 张(screen_1~5.png),并对每张探测真实存在性,
// 仅保留真实可用的 URL,保证"实际有几张就预览几张"。
// 注意:以下拼接路径(APM_STORE_BASE_URL / finalArch / category / pkgname / screen_N.png
// 直接对应服务器静态资源布局,属于权威路径,禁止修改或替换为 app.img_urls 等其它来源,
// 否则会导致截图全部 404(历史已踩坑:后端 img_urls 不准确)。
const loadScreenshots = async (app: App, signal?: AbortSignal) => {
if (!app.category || app.category === "unknown") {
screenshots.value = [];
return;
}
const arch = window.apm_store.arch || "amd64";
const finalArch = app.origin === "spark" ? `${arch}-store` : `${arch}-apm`;
const candidates: string[] = [];
for (let i = 1; i <= 5; i++) {
candidates.push(
`${APM_STORE_BASE_URL}/${finalArch}/${app.category}/${app.pkgname}/screen_${i}.png`,
);
}
// 先按缓存/同步填充已知结果,未知项异步探测;避免详情页打开后长时间空白
const results = await Promise.all(
candidates.map((url) => checkScreenshotExists(url, signal)),
);
screenshots.value = candidates.filter((_, index) => results[index]);
};
const closeDetail = () => {
showModal.value = false;
currentApp.value = null;
// 关闭详情时取消正在进行的截图探测,避免结果写入已关闭的旧应用
if (screenshotAbortController) {
screenshotAbortController.abort();
screenshotAbortController = null;
}
};
const openScreenPreview = (index: number) => {
currentScreenIndex.value = index;
showPreview.value = true;
};
const closeScreenPreview = () => {
showPreview.value = false;
};
const prevScreen = () => {
if (currentScreenIndex.value > 0) {
currentScreenIndex.value--;
}
};
const nextScreen = () => {
if (currentScreenIndex.value < screenshots.value.length - 1) {
currentScreenIndex.value++;
}
};
const selectDetailOrigin = (origin: "spark" | "apm") => {
if (currentApp.value?.isMerged) {
currentApp.value = { ...currentApp.value, viewingOrigin: origin };
}
};
const handleDetailRequestLogin = (message: string) => {
requireLoginRef(message);
};
// requireLogin 定义在 useAccountSync,这里通过函数引用注入,避免循环依赖。
// 使用模块级可赋值引用,由 useAccountSync 在初始化时登记。
let requireLoginRef: (message: string) => boolean = () => true;
export const registerRequireLogin = (fn: (message: string) => boolean) => {
requireLoginRef = fn;
};
// filteredApps 由 App.vue 汇总派生(依赖多个 composable 状态),此处仅做查找辅助,
// 实际引用由 App.vue 通过 setFilteredApps 注入,保证与改造前完全一致。
let filteredAppsSource: Ref<App[]> | null = null;
export const setFilteredApps = (appsRef: Ref<App[]>) => {
filteredAppsSource = appsRef;
};
const filteredAppsFind = (
pkgname: string,
origin: "spark" | "apm" | undefined,
): App | undefined =>
filteredAppsSource?.value.find(
(a) => a.pkgname === pkgname && (!origin || a.origin === origin),
);
export {
clientArch,
currentDisplayApp,
currentReviewAppKey,
currentReviewTags,
fetchAppFromStore,
openDetail,
openDetailFromInstalled,
createFallbackApp,
checkAppInstalled,
loadScreenshots,
closeDetail,
openScreenPreview,
closeScreenPreview,
prevScreen,
nextScreen,
selectDetailOrigin,
handleDetailRequestLogin,
};
export type { OpenDetailInput };
+167
View File
@@ -0,0 +1,167 @@
/**
* useAppState composable
*
*
* App.vue composableuseCatalog / useRanking /
* useAppDetail / useInstalledApps / useFavorites / useAccountSync / useDownloads
* composable apps / currentApp / installedApps /
* favorite* /
*
* "贯穿整个应用生命周期的全局原始状态"
* composable App.vue ref
* global/authState.tsglobal/storeConfig.ts
*
* ** computed**/
* 7 /
*
* currentApp / currentAppSparkInstalled / currentAppApmInstalled
* storeConfig storeConfig
*/
import { ref, computed } from "vue";
import type { Ref } from "vue";
import type {
App,
AppJson,
CategoryInfo,
SidebarEntry,
HomeLink,
FavoriteFolder,
FavoriteItem,
InstalledAppInfo,
ResolvedFavoriteItem,
SystemInfo,
DownloadedAppRecord,
SyncedAppListItem,
AppReview,
ReviewUserProfile,
DownloadItem,
} from "../global/typedefinition";
// ===== 目录数据 =====
export const apps: Ref<App[]> = ref([]);
export const categories: Ref<Record<string, CategoryInfo>> = ref({});
export const tabCategories: Ref<Record<string, Record<string, CategoryInfo>>> =
ref({});
export const tabApps: Ref<Record<string, App[]>> = ref({});
export const loadingTabs = ref<Set<string>>(new Set());
export const homeListUrls = ref<
Record<string, { spark?: string; apm?: string }>
>({});
export const sidebarEntries: Ref<SidebarEntry[]> = ref([]);
export const sparkAvailable = ref(false);
export const apmAvailable = ref(false);
export const storeFilter = ref<"spark" | "apm" | "both">("both");
export const initialCatalogLoaded = ref(false);
export const loading = ref(true);
// ===== 首页 / 排行 =====
export const homeLinks = ref<HomeLink[]>([]);
export const homeLoading = ref(false);
export const homeError = ref("");
export const apmRanking = ref<App[]>([]);
export const sparkRanking = ref<App[]>([]);
export const rankingLoading = ref(false);
// ===== 导航 =====
export type MainView = "default" | "favorites";
export const currentView = ref<MainView>("default");
export const activeTab = ref("home");
export const selectedCategory = ref("all");
export const searchQuery = ref("");
export const isSidebarOpen = ref(false);
// ===== 详情弹窗 =====
export {
currentApp,
currentAppSparkInstalled,
currentAppApmInstalled,
} from "../global/storeConfig";
export const showModal = ref(false);
export const showPreview = ref(false);
export const currentScreenIndex = ref(0);
export const screenshots = ref<string[]>([]);
// ===== 已安装应用 =====
export const showInstalledModal = ref(false);
export const installedApps = ref<App[]>([]);
export const installedLoading = ref(false);
export const installedError = ref("");
export const installedWarning = ref("");
export const installedRefreshGeneration = ref(0);
export const installedSyncPromptShown = ref(false);
// ===== 收藏夹 =====
export const favoriteFolders = ref<FavoriteFolder[]>([]);
export const activeFavoriteFolderId = ref<number | null>(null);
export const favoriteItems = ref<FavoriteItem[]>([]);
export const favoriteItemsByFolder = ref<Record<number, FavoriteItem[]>>({});
export const showFavoriteSelector = ref(false);
export const favoriteTargetApp = ref<App | null>(null);
export const favoriteSelectorDraftFolderIds = ref<Array<
number | "default"
> | null>(null);
export const favoriteLoading = ref(false);
export const favoriteError = ref("");
export const favoriteRequestGeneration = ref(0);
// ===== 账号 / 下载历史 / 云端同步 / 恢复 =====
export const showUserManagementModal = ref(false);
export const downloadedApps = ref<DownloadedAppRecord[]>([]);
export const downloadedLoading = ref(false);
export const downloadedError = ref("");
export const downloadedRequestGeneration = ref(0);
export const syncLoading = ref(false);
export const syncStatusMessage = ref("");
export const syncRequestGeneration = ref(0);
export const syncCandidateApps = ref<App[]>([]);
export const restoreLoading = ref(false);
export const restoreError = ref("");
export const showRestoreModal = ref(false);
export const restoreItems = ref<SyncedAppListItem[]>([]);
export const restoreRequestGeneration = ref(0);
export const showLoginModal = ref(false);
export const loginLoading = ref(false);
export const loginError = ref("");
export const showLoginPrompt = ref(false);
export const loginPromptMessage = ref("请登录星火账号后继续操作。");
export const showLoginPromptMessage = loginPromptMessage;
export const showReviewUserProfileModal = ref(false);
export const selectedReviewUserProfile = ref<ReviewUserProfile | null>(null);
export const systemInfo = ref<SystemInfo>({ distro: "unknown" });
// ===== 下载队列 =====
export { downloads } from "../global/downloadStatus";
export const showDownloadDetailModal = ref(false);
export const currentDownload: Ref<DownloadItem | null> = ref(null);
// ===== 其它弹窗 / 卸载 / 更新中心编排 =====
export const showUninstallModal = ref(false);
export const uninstallTargetApp: Ref<App | null> = ref(null);
export const showAboutModal = ref(false);
export const showSettingsModal = ref(false);
export const showApmInstallDialog = ref(false);
// 可用来源 computed(供 storeFilter 相关判断复用)
export const availableSources = computed(() => ({
spark: sparkAvailable.value,
apm: apmAvailable.value,
}));
// 运算符别名(仅导出类型引用,避免重复定义)
export type {
App,
AppJson,
CategoryInfo,
SidebarEntry,
HomeLink,
FavoriteFolder,
FavoriteItem,
InstalledAppInfo,
ResolvedFavoriteItem,
SystemInfo,
DownloadedAppRecord,
SyncedAppListItem,
AppReview,
ReviewUserProfile,
DownloadItem,
};
+596
View File
@@ -0,0 +1,596 @@
/**
* useCatalog / /
*
* App.vue loadCategories / loadSidebarConfig / loadTabCategories /
* loadTabApps / loadApps / normalizeAppJson / loadHomeListEntries / loadHomeListApps /
* preloadHomeListApps / preloadSidebarTabApps
*
* apps / categories / tabCategories / tabApps / loadingTabs /
* homeListUrls / sidebarEntries / initialCatalogLoaded useAppState
*/
import type {
App,
AppJson,
CategoryInfo,
SidebarEntry,
} from "../global/typedefinition";
import {
apps,
categories,
tabCategories,
tabApps,
loadingTabs,
homeListUrls,
sidebarEntries,
initialCatalogLoaded,
storeFilter,
} from "./useAppState";
import { axiosInstance, fetchWithRetry, rootAbortController } from "./useHttp";
import { loadPriorityConfig, APM_STORE_BASE_URL } from "../global/storeConfig";
export const loadCategories = async () => {
try {
const arch = window.apm_store.arch || "amd64";
const modes: Array<"spark" | "apm"> = storeFilterMode();
const categoryData: Record<string, { zh: string; origins: string[] }> = {};
for (const mode of modes) {
const finalArch = mode === "spark" ? `${arch}-store` : `${arch}-apm`;
const path = `/${finalArch}/categories.json`;
try {
const response = await axiosInstance.get(path);
const data = response.data;
Object.keys(data).forEach((key) => {
if (categoryData[key]) {
if (!categoryData[key].origins.includes(mode)) {
categoryData[key].origins.push(mode);
}
} else {
categoryData[key] = {
zh: data[key].zh || data[key],
origins: [mode],
};
}
});
} catch (e) {
// 读取 categories.json 失败(如某来源无此文件),静默忽略该来源
console.warn(`读取 ${mode} categories.json 失败:`, e);
}
}
categories.value = categoryData;
// 加载优先级配置(从 spark 目录)
await loadPriorityConfig(arch);
} catch (error) {
console.error(`读取 categories 失败:`, error);
}
};
// 远程 sidebar-config.json 入口的类型守卫:在写入渲染层前拦截异常字段。
// 字段类型/长度限制防止畸形数据(如超长 name 撑破布局、非法 type 触发未知分支)。
// type 白名单必须与 global/typedefinition.ts 的 SidebarEntry.type 保持一致。
const VALID_SIDEBAR_TYPES = ["category", "search", "link", "homeList"] as const;
const isValidSidebarEntry = (e: unknown): e is SidebarEntry => {
if (typeof e !== "object" || e === null) return false;
const entry = e as Record<string, unknown>;
if (
typeof entry.id !== "string" ||
entry.id.length === 0 ||
entry.id.length > 64
)
return false;
if (
typeof entry.name !== "string" ||
entry.name.length === 0 ||
entry.name.length > 128
)
return false;
if (entry.icon !== undefined && typeof entry.icon !== "string") return false;
if (
entry.type !== undefined &&
!VALID_SIDEBAR_TYPES.includes(entry.type as (typeof VALID_SIDEBAR_TYPES)[number])
)
return false;
if (entry.value !== undefined && typeof entry.value !== "string") return false;
return true;
};
export const loadSidebarConfig = async () => {
try {
const arch = window.apm_store.arch || "amd64";
const modes: Array<"spark" | "apm"> = storeFilterMode();
const entryMap = new Map<string, SidebarEntry>();
for (const mode of modes) {
const finalArch = mode === "spark" ? `${arch}-store` : `${arch}-apm`;
const path = `/${finalArch}/sidebar-config.json`;
try {
const response = await axiosInstance.get(path);
const data = response.data;
const entries = Array.isArray(data) ? data : data.entries || [];
for (const entry of entries) {
// 严格校验远程配置,避免畸形/恶意字段进入渲染层(Vue 模板自动转义已兜底 XSS)
if (!isValidSidebarEntry(entry)) continue;
const existing = entryMap.get(entry.id);
if (existing) {
// 多仓库共有入口,合并来源
if (existing.origins && !existing.origins.includes(mode)) {
existing.origins.push(mode);
}
} else {
entryMap.set(entry.id, {
id: entry.id,
name: entry.name,
icon: entry.icon ?? "",
type: entry.type ?? "category",
value: entry.value ?? entry.id,
origins: [mode],
});
}
}
} catch (e) {
console.warn(`读取 ${mode} sidebar-config.json 失败:`, e);
}
}
sidebarEntries.value = Array.from(entryMap.values());
if (sidebarEntries.value.length > 0) {
console.info(`已加载 ${sidebarEntries.value.length} 个侧边栏配置入口`);
}
} catch (error) {
console.warn(`读取 sidebar-config 失败:`, error);
}
};
export const normalizeAppJson = (
appJson: AppJson,
category: string,
origin: "spark" | "apm",
): App => ({
name: appJson.Name,
pkgname: appJson.Pkgname,
version: appJson.Version,
filename: appJson.Filename,
torrent_address: appJson.Torrent_address,
author: appJson.Author,
contributor: appJson.Contributor,
website: appJson.Website,
update: appJson.Update,
size: appJson.Size,
more: appJson.More,
tags: appJson.Tags,
img_urls: (() => {
if (typeof appJson.img_urls === "string") {
try {
return JSON.parse(appJson.img_urls) as string[];
} catch {
return [];
}
}
return (appJson.img_urls as string[]) || [];
})(),
icons: appJson.icons,
category: category,
origin: origin,
currentStatus: "not-installed" as const,
});
export const loadTabCategories = async () => {
const arch = window.apm_store.arch || "amd64";
const modes: Array<"spark" | "apm"> = storeFilterMode();
const newTabCategories: Record<string, Record<string, CategoryInfo>> = {};
// 并行加载所有侧边栏入口的子分类,减少串行等待
const categoryEntries = sidebarEntries.value.filter(
(e) => e.type === "category",
);
await Promise.all(
categoryEntries.map(async (entry) => {
const folderName = entry.value || entry.id;
const catData: Record<string, { zh: string; origins: string[] }> = {};
// 只查询该入口实际存在的来源仓库,避免对不存在目录的 404 重试
const entryModes = entry.origins?.length
? entry.origins.filter((o) => modes.includes(o))
: modes;
await Promise.all(
entryModes.map(async (mode) => {
const finalArch = mode === "spark" ? `${arch}-store` : `${arch}-apm`;
const path = `/${finalArch}/${folderName}/categories.json`;
try {
const response = await axiosInstance.get(path);
const data = response.data;
Object.keys(data).forEach((key) => {
if (catData[key]) {
if (!catData[key].origins.includes(mode)) {
catData[key].origins.push(mode);
}
} else {
catData[key] = {
zh: data[key].zh || data[key],
origins: [mode],
};
}
});
} catch {
// 该入口没有子分类,静默忽略
}
}),
);
if (Object.keys(catData).length > 0) {
newTabCategories[entry.id] = catData;
console.info(
`入口 "${entry.id}" 加载到 ${Object.keys(catData).length} 个子分类`,
);
}
}),
);
tabCategories.value = newTabCategories;
};
export const loadTabApps = async (entryId: string) => {
if (tabApps.value[entryId]) return;
// 防止重复加载:如果正在加载中则跳过
if (loadingTabs.value.has(entryId)) return;
const entry = sidebarEntries.value.find((e) => e.id === entryId);
if (!entry || entry.type !== "category") return;
// 标记为加载中
loadingTabs.value = new Set(loadingTabs.value).add(entryId);
const arch = window.apm_store.arch || "amd64";
const allModes: Array<"spark" | "apm"> = storeFilterMode();
// 只查询该入口实际存在的来源仓库,避免对不存在目录的 404 重试
const modes = entry.origins?.length
? entry.origins.filter((o) => allModes.includes(o))
: allModes;
const folderName = entry.value || entry.id;
const subCats = tabCategories.value[entryId];
// 收集所有需要发起的请求任务(mode × 子分类),然后全并发加载
const tasks: Promise<App[]>[] = [];
for (const mode of modes) {
const finalArch = mode === "spark" ? `${arch}-store` : `${arch}-apm`;
if (subCats && Object.keys(subCats).length > 0) {
for (const [subCat, catInfo] of Object.entries(subCats)) {
if (
catInfo.origins &&
catInfo.origins.length > 0 &&
!catInfo.origins.includes(mode)
)
continue;
const path = `/${finalArch}/${folderName}/${subCat}/applist.json`;
console.info(`加载入口子分类: ${entryId}/${subCat} (来源: ${mode})`);
tasks.push(
fetchWithRetry<AppJson[]>(path, rootAbortController.signal)
.then((categoryApps) =>
(categoryApps || []).map((aj) =>
normalizeAppJson(aj, subCat, mode),
),
)
.catch((e: unknown) => {
console.warn(
`加载入口子分类 ${entryId}/${subCat} (${mode}) 失败:`,
e,
);
return [] as App[];
}),
);
}
} else {
const path = `/${finalArch}/${folderName}/applist.json`;
console.info(`加载入口目录: ${entryId} (来源: ${mode})`);
tasks.push(
fetchWithRetry<AppJson[]>(path, rootAbortController.signal)
.then((categoryApps) =>
(categoryApps || []).map((aj) =>
normalizeAppJson(aj, folderName, mode),
),
)
.catch((e: unknown) => {
console.warn(`加载入口目录 ${entryId} (${mode}) 失败:`, e);
return [] as App[];
}),
);
}
}
const results = await Promise.all(tasks);
const loadedApps = results.flat();
tabApps.value = { ...tabApps.value, [entryId]: loadedApps };
// 移除加载标记
const next = new Set(loadingTabs.value);
next.delete(entryId);
loadingTabs.value = next;
console.info(`入口 "${entryId}" 加载完成,共 ${loadedApps.length} 个应用`);
};
export const loadApps = async (onFirstBatch?: () => void) => {
try {
console.info("开始加载应用数据(全并发带重试)...");
const categoriesList = Object.keys(categories.value || {});
let firstBatchCallDone = false;
const arch = window.apm_store.arch || "amd64";
// 并发加载所有分类,每个分类自带重试机制
await Promise.all(
categoriesList.map(async (category) => {
const catInfo = categories.value[category];
if (!catInfo) return;
const origins = (catInfo.origins ||
(catInfo.origin ? [catInfo.origin] : [])) as string[];
await Promise.all(
origins.map(async (mode) => {
try {
const finalArch =
mode === "spark" ? `${arch}-store` : `${arch}-apm`;
const path = `/${finalArch}/${category}/applist.json`;
console.info(`加载分类: ${category} (来源: ${mode})`);
const categoryApps = await fetchWithRetry<AppJson[]>(
path,
rootAbortController.signal,
);
const normalizedApps = (categoryApps || []).map((appJson) =>
normalizeAppJson(appJson, category, mode as "spark" | "apm"),
);
// 增量式更新,让用户尽快看到部分数据
// 用赋值替代 push(...),避免对响应式数组逐元素触发 re-render
apps.value = [...apps.value, ...normalizedApps];
// 只要有一个分类加载成功,就可以考虑关闭整体 loading(如果是首批逻辑)
if (!firstBatchCallDone && typeof onFirstBatch === "function") {
firstBatchCallDone = true;
onFirstBatch();
// 标记初始目录加载完成,使 apps.length watcher 开始在目录变更时刷新已安装列表
initialCatalogLoaded.value = true;
}
} catch (error) {
console.warn(
`加载分类 ${category} 来源 ${mode} 最终失败:`,
error,
);
}
}),
);
}),
);
// 确保即使全部失败也结束 loading
if (!firstBatchCallDone && typeof onFirstBatch === "function") {
onFirstBatch();
}
} catch (error) {
console.error(`加载应用数据流程异常:`, error);
}
};
// 加载首页推荐列表为侧边栏入口(按名称合并 spark/apm,置于分类入口上方)
export const loadHomeListEntries = async () => {
try {
const arch = window.apm_store.arch || "amd64";
const modes: Array<"spark" | "apm"> = storeFilterMode();
// 按列表名称合并各来源的 jsonUrl
const byName = new Map<
string,
{ name: string; urls: { spark?: string; apm?: string } }
>();
// 并发拉取各来源的 homelist.jsonspark/apm),缩短首页入口加载耗时;
// 各来源独立解析后合并到局部 byName,不逐个触发响应式更新。
await Promise.all(
modes.map(async (mode) => {
const finalArch = mode === "spark" ? `${arch}-store` : `${arch}-apm`;
const base = `${APM_STORE_BASE_URL}/${finalArch}/home`;
try {
const res = await fetch(`${base}/homelist.json`);
if (!res.ok) return;
const lists = await res.json();
lists.forEach(
(item: { name?: string; type?: string; jsonUrl?: string }) => {
if (item.type === "appList" && item.jsonUrl) {
const name = item.name || "推荐";
const existing = byName.get(name);
if (existing) {
existing.urls[mode] = item.jsonUrl;
} else {
byName.set(name, {
name,
urls: { [mode]: item.jsonUrl } as {
spark?: string;
apm?: string;
},
});
}
}
},
);
} catch (e) {
console.warn(`Failed to load ${mode} homelist.json`, e);
}
}),
);
const entries: SidebarEntry[] = [];
const urlsMap: Record<string, { spark?: string; apm?: string }> = {};
byName.forEach((info, name) => {
const id = `home-list-${name}`;
entries.push({
id,
name,
icon: "fas fa-star",
type: "homeList",
});
urlsMap[id] = info.urls;
});
if (entries.length > 0) {
// 首页推荐入口置于分类入口上方
sidebarEntries.value = [...entries, ...sidebarEntries.value];
homeListUrls.value = { ...homeListUrls.value, ...urlsMap };
console.info(`已加载 ${entries.length} 个首页推荐列表入口`);
}
} catch (error) {
console.warn(`加载首页推荐列表入口失败: ${error}`);
}
};
// 加载首页推荐列表的应用数据(合并展示 spark+apm,按 pkgname 去重,spark 优先)
export const loadHomeListApps = async (entryId: string) => {
if (tabApps.value[entryId]) return;
// 防止重复加载:如果正在加载中则跳过
if (loadingTabs.value.has(entryId)) return;
const urls = homeListUrls.value[entryId];
if (!urls) return;
// 标记为加载中
loadingTabs.value = new Set(loadingTabs.value).add(entryId);
const arch = window.apm_store.arch || "amd64";
const loadedApps: App[] = [];
const seenPkgnames = new Set<string>();
const parseAppList = (
rawApps: Record<string, string>[],
mode: "spark" | "apm",
): App[] =>
rawApps.map((a) => {
// 首页推荐列表的 jsonUrl 形如 /home/lists/xxx.json;服务端原始数据不含 category 字段,
// 这里提取 URL 首段目录作为分类(无真实分类时回退 "unknown"),避免污染后续优先级匹配。
const urlCategory =
(urls[mode] || "").split("/").filter(Boolean)[0] || "unknown";
const category = a.Category || a.category || urlCategory;
let img_urls: string[] = [];
const rawImgUrls = a.img_urls;
if (typeof rawImgUrls === "string") {
try {
img_urls = JSON.parse(rawImgUrls);
} catch {
img_urls = [];
}
} else if (Array.isArray(rawImgUrls)) {
img_urls = rawImgUrls;
}
return {
name: a.Name || a.name || a.Pkgname || a.pkgname || "",
pkgname: a.Pkgname || a.pkgname || "",
version: a.Version || "",
filename: a.Filename || a.filename || "",
torrent_address: a.Torrent_address || "",
author: a.Author || "",
contributor: a.Contributor || "",
website: a.Website || "",
update: a.Update || "",
size: a.Size || "",
more: a.More || a.more || "",
tags: a.Tags || "",
img_urls,
icons: a.icons || "",
category,
origin: mode,
currentStatus: "not-installed" as const,
} as App;
});
// 按优先级顺序加载:spark 优先,apm 中与 spark 同名的跳过
const modes: Array<"spark" | "apm"> = ["spark", "apm"];
for (const mode of modes) {
const jsonUrl = urls[mode];
if (!jsonUrl) continue;
const finalArch = mode === "spark" ? `${arch}-store` : `${arch}-apm`;
try {
const path = `/${finalArch}${jsonUrl}`;
const rawApps =
(await fetchWithRetry<Record<string, string>[]>(
path,
rootAbortController.signal,
)) || [];
const apps = parseAppList(rawApps, mode);
for (const app of apps) {
if (!app.pkgname || seenPkgnames.has(app.pkgname)) continue;
seenPkgnames.add(app.pkgname);
loadedApps.push(app);
}
} catch (e) {
console.warn(`加载首页列表 ${entryId} (${mode}) 失败:`, e);
}
}
tabApps.value = { ...tabApps.value, [entryId]: loadedApps };
// 移除加载标记
const next = new Set(loadingTabs.value);
next.delete(entryId);
loadingTabs.value = next;
console.info(
`首页列表 "${entryId}" 加载完成,共 ${loadedApps.length} 个应用`,
);
};
// 仅并行预加载首页 homeList 板块入口(区域2 数据来源,不依赖全量应用)
export const preloadHomeListApps = (): Promise<void> => {
const tasks: Promise<void>[] = [];
for (const entry of sidebarEntries.value) {
if (entry.type === "homeList") {
tasks.push(
loadHomeListApps(entry.id).catch((e: unknown) =>
console.warn(`预加载首页列表 ${entry.id} 失败:`, e),
),
);
}
}
return Promise.all(tasks).then(() => undefined);
};
// 并行预加载其余分类侧边栏入口(用户点击分类时才需要,可延后)
export const preloadSidebarTabApps = (): Promise<void> => {
const tasks: Promise<void>[] = [];
for (const entry of sidebarEntries.value) {
if (entry.type === "category") {
tasks.push(
loadTabApps(entry.id).catch((e: unknown) =>
console.warn(`预加载入口 ${entry.id} 失败:`, e),
),
);
}
}
return Promise.all(tasks).then(() => undefined);
};
// 来源模式解析:both -> [spark, apm],否则单一来源
function storeFilterMode(): Array<"spark" | "apm"> {
return storeFilter.value === "both" ? ["spark", "apm"] : [storeFilter.value];
}
+233
View File
@@ -0,0 +1,233 @@
/**
* useDownloads
*
* App.vue pendingDownloadRecords / onDetailInstall /
* handleInstallCompleteForDownloadRecord / onDetailRemove / onDetailFavorite /
* pauseDownload / resumeDownload / cancelDownload / retryDownload /
* clearCompletedDownloads / showDownloadDetailModalFunc / closeDownloadDetail /
* openDownloadedApp / installCompleteCallback / watchDownloadsChange
*
* useAppStatehandleInstall / handleRetry modules/processInstall
* openFavoriteSelector useFavorites
*/
import type {
App,
DownloadItem,
DownloadResult,
DownloadedAppRecord,
} from "../global/typedefinition";
import type { IpcRendererEvent } from "electron";
import {
downloads,
currentDownload,
showDownloadDetailModal,
currentApp,
} from "./useAppState";
import { handleInstall, handleRetry } from "../modules/processInstall";
import { watchDownloadsChange } from "../global/downloadStatus";
import { buildFavoriteAppKey, parsePackageArch } from "../modules/appIdentity";
import { openFavoriteSelector } from "./useFavorites";
import { recordDownloadedApp } from "../modules/backendApi";
import { isLoggedIn, currentUser } from "../global/authState";
interface PendingDownloadRecord {
userId: number;
appKey: string;
pkgname: string;
name: string;
category: string;
selectedOrigin: "spark" | "apm";
version: string;
packageArch: string;
}
const pendingDownloadRecords = new Map<number, PendingDownloadRecord>();
const onDetailInstall = async (app: App) => {
const initiatingUserId = currentUser.value?.id ?? null;
const download = await handleInstall(app);
if (
!download ||
initiatingUserId === null ||
!isLoggedIn.value ||
currentUser.value?.id !== initiatingUserId
) {
return;
}
pendingDownloadRecords.set(download.id, {
userId: initiatingUserId,
appKey: buildFavoriteAppKey(app),
pkgname: app.pkgname,
name: app.name,
category: app.category,
selectedOrigin: app.origin,
version: app.version,
packageArch: app.arch || parsePackageArch(app.filename),
});
};
const handleInstallCompleteForDownloadRecord = async (
_event: IpcRendererEvent,
result: DownloadResult,
) => {
const pendingRecord = pendingDownloadRecords.get(result.id);
if (!pendingRecord) return;
if (result.success) {
pendingDownloadRecords.delete(result.id);
}
if (
!result.success ||
!isLoggedIn.value ||
currentUser.value?.id !== pendingRecord.userId
) {
return;
}
const downloadRecord: Omit<DownloadedAppRecord, "id" | "downloadedAt"> = {
appKey: pendingRecord.appKey,
pkgname: pendingRecord.pkgname,
name: pendingRecord.name,
category: pendingRecord.category,
selectedOrigin: pendingRecord.selectedOrigin,
version: pendingRecord.version,
packageArch: pendingRecord.packageArch,
};
try {
await recordDownloadedApp(downloadRecord);
} catch (error: unknown) {
console.warn({ err: error }, "记录下载应用失败");
}
};
const onDetailRemove = (app: App) => {
requestUninstallRef(app);
};
const onDetailFavorite = async (app: App) => {
await openFavoriteSelector(app);
};
// TODO: 目前 APM 商店不能暂停下载
const pauseDownload = (id: DownloadItem) => {
const download = downloads.value.find((d) => d.id === id.id);
if (download && download.status === "installing") {
// 'installing' matches type definition, previously 'downloading'
download.status = "paused";
download.logs.push({
time: Date.now(),
message: "下载已暂停",
});
}
};
// TODO: 同理,暂未实现
const resumeDownload = (id: DownloadItem) => {
const download = downloads.value.find((d) => d.id === id.id);
if (download && download.status === "paused") {
download.status = "installing"; // previously 'downloading'
download.logs.push({
time: Date.now(),
message: "继续下载...",
});
// simulateDownload(download); // removed or undefined?
}
};
const cancelDownload = (id: DownloadItem) => {
const index = downloads.value.findIndex((d) => d.id === id.id);
if (index !== -1) {
const download = downloads.value[index];
// 发送到主进程取消
window.ipcRenderer.send("cancel-install", download.id);
download.status = "failed";
download.logs.push({
time: Date.now(),
message: "下载已取消",
});
// 保留在队列中以便用户可以重试或查看日志
}
};
const retryDownload = (id: DownloadItem) => {
const download = downloads.value.find((d) => d.id === id.id);
if (download && download.status === "failed") {
download.status = "queued";
download.progress = 0;
download.downloadedSize = 0;
download.logs.push({
time: Date.now(),
message: "重新开始下载...",
});
handleRetry(download);
}
};
const clearCompletedDownloads = () => {
downloads.value = downloads.value.filter((d) => d.status !== "completed");
};
const showDownloadDetailModalFunc = (download: DownloadItem) => {
currentDownload.value = download;
showDownloadDetailModal.value = true;
};
const closeDownloadDetail = () => {
showDownloadDetailModal.value = false;
currentDownload.value = null;
};
const openDownloadedApp = (pkgname: string, origin?: "spark" | "apm") => {
// const encodedPkg = encodeURIComponent(download.pkgname);
// openApmStoreUrl(`apmstore://launch?pkg=${encodedPkg}`, {
// fallbackText: `打开应用: ${download.pkgname}`
// });
window.ipcRenderer
.invoke("launch-app", { pkgname, origin })
.catch((err) => console.error("启动应用失败 (launch-app):", err));
};
const installCompleteCallback = (pkgname?: string) => {
if (currentApp.value && (!pkgname || currentApp.value.pkgname === pkgname)) {
checkAppInstalledRef(currentApp.value);
}
};
watchDownloadsChange(installCompleteCallback);
// checkAppInstalled 由 useAppDetail 持有,通过注入引用获取,避免循环依赖
let checkAppInstalledRef: (app: App) => void = () => undefined;
export const registerCheckAppInstalled = (fn: (app: App) => void) => {
checkAppInstalledRef = fn;
};
// requestUninstall 由 useInstalledApps 持有,通过注入引用获取,避免循环依赖
let requestUninstallRef: (app: App) => void = () => undefined;
export const registerRequestUninstall = (fn: (app: App) => void) => {
requestUninstallRef = fn;
};
// 登出时清空待下载记录(原 App.vue logout 中的 pendingDownloadRecords.clear()
export const clearPendingDownloadRecords = (): void => {
pendingDownloadRecords.clear();
};
// onDetailInstall 需要被 useFavorites / useAccountSync 调用,此处导出
export {
pendingDownloadRecords,
onDetailInstall,
handleInstallCompleteForDownloadRecord,
onDetailRemove,
onDetailFavorite,
pauseDownload,
resumeDownload,
cancelDownload,
retryDownload,
clearCompletedDownloads,
showDownloadDetailModalFunc,
closeDownloadDetail,
openDownloadedApp,
installCompleteCallback,
};
+489
View File
@@ -0,0 +1,489 @@
/**
* useFavorites
*
* App.vue loadFavoriteFolders / loadActiveFavoriteItems /
* loadAllFavoriteItems / loadFavoriteMetadataForDetail / refreshFavorites /
* openFavoriteSelector / toFavoritePayload / saveCurrentFavoriteFolders /
* createFavoriteFolderFromSelector / openFavoriteManagement / selectFavoriteFolder /
* createFavoriteFolderFromPrompt / removeSelectedFavorites / installResolvedFavorites /
* clearFavoriteState / / currentFavoriteMetadata / currentFavoriteFolderIds /
* resolvedFavoriteItems
*
* useAppStateclientArch useAppDetail
*/
import { computed } from "vue";
import type {
App,
FavoriteItem,
ResolvedFavoriteItem,
} from "../global/typedefinition";
import {
favoriteFolders,
favoriteItems,
favoriteItemsByFolder,
favoriteLoading,
favoriteError,
favoriteRequestGeneration,
favoriteTargetApp,
favoriteSelectorDraftFolderIds,
activeFavoriteFolderId,
showFavoriteSelector,
currentView,
activeTab,
isSidebarOpen,
showLoginPrompt,
apps,
installedApps,
availableSources,
storeFilter,
} from "./useAppState";
import { clientArch } from "./useAppDetail";
import {
listFavoriteFolders,
listFavoriteItems,
addFavoriteItem,
deleteFavoriteItem,
createFavoriteFolder,
bulkDeleteFavoriteItems,
} from "../modules/backendApi";
import { buildFavoriteAppKey } from "../modules/appIdentity";
import { resolveFavoriteItems } from "../modules/favoriteAvailability";
import { isLoggedIn } from "../global/authState";
import { refreshFavoriteInstalledApps } from "./useInstalledApps";
const nextFavoriteRequestGeneration = (): number => {
favoriteRequestGeneration.value += 1;
return favoriteRequestGeneration.value;
};
const isCurrentFavoriteRequest = (generation: number): boolean =>
favoriteRequestGeneration.value === generation && isLoggedIn.value;
const loadFavoriteFolders = async (
generation = favoriteRequestGeneration.value,
): Promise<boolean> => {
const folders = await listFavoriteFolders();
if (!isCurrentFavoriteRequest(generation)) return false;
favoriteFolders.value = folders;
const activeFolderExists = folders.some(
(folder) => folder.id === activeFavoriteFolderId.value,
);
if (!activeFolderExists) {
activeFavoriteFolderId.value = folders[0]?.id ?? null;
}
return true;
};
const loadActiveFavoriteItems = async (
generation = favoriteRequestGeneration.value,
): Promise<boolean> => {
if (!activeFavoriteFolderId.value) {
if (!isCurrentFavoriteRequest(generation)) return false;
favoriteItems.value = [];
return true;
}
const items = await listFavoriteItems(activeFavoriteFolderId.value);
if (!isCurrentFavoriteRequest(generation)) return false;
favoriteItems.value = items;
favoriteItemsByFolder.value = {
...favoriteItemsByFolder.value,
[activeFavoriteFolderId.value]: items,
};
return true;
};
const loadAllFavoriteItems = async (
generation = favoriteRequestGeneration.value,
): Promise<boolean> => {
const folderIds = favoriteFolders.value.map((folder) => folder.id);
const entries = await Promise.all(
folderIds.map(async (folderId) => ({
folderId,
items: await listFavoriteItems(folderId),
})),
);
if (!isCurrentFavoriteRequest(generation)) return false;
favoriteItemsByFolder.value = Object.fromEntries(
entries.map(({ folderId, items }) => [folderId, items]),
);
favoriteItems.value = activeFavoriteFolderId.value
? (favoriteItemsByFolder.value[activeFavoriteFolderId.value] ?? [])
: [];
return true;
};
const loadFavoriteMetadataForDetail = async (): Promise<void> => {
const generation = favoriteRequestGeneration.value;
try {
const folders = await listFavoriteFolders();
if (!isCurrentFavoriteRequest(generation)) return;
const entries = await Promise.all(
folders.map(async (folder) => ({
folderId: folder.id,
items: await listFavoriteItems(folder.id),
})),
);
if (!isCurrentFavoriteRequest(generation)) return;
favoriteFolders.value = folders;
favoriteItemsByFolder.value = Object.fromEntries(
entries.map(({ folderId, items }) => [folderId, items]),
);
} catch (error: unknown) {
if (!isCurrentFavoriteRequest(generation)) return;
favoriteError.value = (error as Error)?.message || "读取收藏夹失败";
}
};
const refreshFavorites = async (): Promise<void> => {
const generation = nextFavoriteRequestGeneration();
favoriteLoading.value = true;
favoriteError.value = "";
try {
await Promise.all([
refreshFavoriteInstalledApps(),
loadFavoriteFolders(generation),
]);
if (!isCurrentFavoriteRequest(generation)) return;
await loadAllFavoriteItems(generation);
} catch (error: unknown) {
if (!isCurrentFavoriteRequest(generation)) return;
favoriteError.value = (error as Error)?.message || "读取收藏夹失败";
} finally {
if (isCurrentFavoriteRequest(generation)) favoriteLoading.value = false;
}
};
const openFavoriteSelector = async (app: App) => {
if (!requireLoginRef("收藏应用需要登录星火账号。")) return;
const generation = nextFavoriteRequestGeneration();
favoriteTargetApp.value = app;
favoriteSelectorDraftFolderIds.value = null;
favoriteError.value = "";
try {
const foldersLoaded = await loadFavoriteFolders(generation);
if (!foldersLoaded || !isCurrentFavoriteRequest(generation)) return;
const itemsLoaded = await loadAllFavoriteItems(generation);
if (!itemsLoaded || !isCurrentFavoriteRequest(generation)) return;
showFavoriteSelector.value = true;
} catch (error: unknown) {
if (!isCurrentFavoriteRequest(generation)) return;
favoriteError.value = (error as Error)?.message || "读取收藏夹失败";
}
};
const toFavoritePayload = (
app: App,
): Omit<FavoriteItem, "id" | "createdAt"> => ({
appKey: buildFavoriteAppKey(app),
pkgname: app.pkgname,
name: app.name,
category: app.category,
iconUrl: app.icons,
});
const saveCurrentFavoriteFolders = async (
folderIds: Array<number | "default">,
) => {
const generation = favoriteRequestGeneration.value;
const app = favoriteTargetApp.value;
if (!app) return;
try {
const numericFolderIds = folderIds.filter(
(folderId): folderId is number => typeof folderId === "number",
);
const includesFallbackDefault = folderIds.includes("default");
const nextFolderIds = new Set(numericFolderIds);
const existingByFolder = favoriteFolders.value
.map((folder) => ({
folderId: folder.id,
item: (favoriteItemsByFolder.value[folder.id] ?? []).find(
(favorite) =>
favorite.pkgname === app.pkgname &&
favorite.category === app.category,
),
}))
.filter(
(entry): entry is { folderId: number; item: FavoriteItem } =>
entry.item !== undefined,
);
const existingFolderIds = new Set(
existingByFolder.map((entry) => entry.folderId),
);
const payload = toFavoritePayload(app);
const addedItemPromises = numericFolderIds
.filter((folderId) => !existingFolderIds.has(folderId))
.map(async (folderId) => ({
folderId,
item: await addFavoriteItem(folderId, payload),
}));
const deletedEntries = existingByFolder.filter(
({ folderId }) => !nextFolderIds.has(folderId),
);
const [addedEntries] = await Promise.all([
Promise.all(addedItemPromises),
...(includesFallbackDefault ? [addFavoriteItem("default", payload)] : []),
...deletedEntries.map(({ folderId, item }) =>
deleteFavoriteItem(folderId, item.id),
),
]);
if (!isCurrentFavoriteRequest(generation)) return;
const nextItemsByFolder = { ...favoriteItemsByFolder.value };
deletedEntries.forEach(({ folderId, item }) => {
nextItemsByFolder[folderId] = (nextItemsByFolder[folderId] ?? []).filter(
(favorite) => favorite.id !== item.id,
);
});
addedEntries.forEach(({ folderId, item }) => {
nextItemsByFolder[folderId] = [
...(nextItemsByFolder[folderId] ?? []).filter(
(favorite) =>
favorite.pkgname !== app.pkgname ||
favorite.category !== app.category,
),
item,
];
});
favoriteItemsByFolder.value = nextItemsByFolder;
if (activeFavoriteFolderId.value) {
favoriteItems.value =
nextItemsByFolder[activeFavoriteFolderId.value] ?? [];
}
showFavoriteSelector.value = false;
favoriteTargetApp.value = null;
favoriteSelectorDraftFolderIds.value = null;
if (includesFallbackDefault) await refreshFavorites();
} catch (error: unknown) {
if (!isCurrentFavoriteRequest(generation)) return;
favoriteError.value = (error as Error)?.message || "更新收藏失败";
}
};
const createFavoriteFolderFromSelector = async (
draftFolderIds: Array<
number | "default"
> = favoriteSelectorDraftFolderIds.value ?? [],
): Promise<void> => {
const generation = favoriteRequestGeneration.value;
const name = window.prompt("请输入收藏夹名称");
const folderName = name?.trim();
if (!folderName) return;
const app = favoriteTargetApp.value;
if (!app) return;
favoriteLoading.value = true;
favoriteError.value = "";
try {
const folder = await createFavoriteFolder(folderName);
if (!isCurrentFavoriteRequest(generation)) return;
favoriteFolders.value = [
...favoriteFolders.value.filter((item) => item.id !== folder.id),
folder,
];
favoriteItemsByFolder.value = {
...favoriteItemsByFolder.value,
[folder.id]: favoriteItemsByFolder.value[folder.id] ?? [],
};
favoriteSelectorDraftFolderIds.value = [
...new Set([...draftFolderIds, folder.id]),
];
showFavoriteSelector.value = true;
} catch (error: unknown) {
if (!isCurrentFavoriteRequest(generation)) return;
favoriteError.value = (error as Error)?.message || "创建收藏夹失败";
} finally {
if (isCurrentFavoriteRequest(generation)) favoriteLoading.value = false;
}
};
const openFavoriteManagement = async () => {
if (!requireLoginRef("请登录后查看我的收藏。")) return;
currentView.value = "favorites";
activeTab.value = "favorites";
isSidebarOpen.value = false;
showLoginPrompt.value = false;
await refreshFavorites();
};
const selectFavoriteFolder = async (folderId: number) => {
const generation = nextFavoriteRequestGeneration();
activeFavoriteFolderId.value = folderId;
favoriteLoading.value = true;
favoriteError.value = "";
try {
await loadActiveFavoriteItems(generation);
} catch (error: unknown) {
if (!isCurrentFavoriteRequest(generation)) return;
favoriteError.value = (error as Error)?.message || "读取收藏应用失败";
} finally {
if (isCurrentFavoriteRequest(generation)) favoriteLoading.value = false;
}
};
const createFavoriteFolderFromPrompt = async () => {
const name = window.prompt("请输入收藏夹名称");
const folderName = name?.trim();
if (!folderName) return;
const generation = favoriteRequestGeneration.value;
favoriteLoading.value = true;
favoriteError.value = "";
try {
const folder = await createFavoriteFolder(folderName);
if (!isCurrentFavoriteRequest(generation)) return;
favoriteFolders.value = [
...favoriteFolders.value.filter((item) => item.id !== folder.id),
folder,
];
favoriteItemsByFolder.value = {
...favoriteItemsByFolder.value,
[folder.id]: favoriteItemsByFolder.value[folder.id] ?? [],
};
favoriteItems.value = [];
activeFavoriteFolderId.value = folder.id;
} catch (error: unknown) {
if (!isCurrentFavoriteRequest(generation)) return;
favoriteError.value = (error as Error)?.message || "创建收藏夹失败";
} finally {
if (isCurrentFavoriteRequest(generation)) favoriteLoading.value = false;
}
};
const removeSelectedFavorites = async (ids: number[]) => {
if (!activeFavoriteFolderId.value || ids.length === 0) return;
const generation = favoriteRequestGeneration.value;
favoriteLoading.value = true;
favoriteError.value = "";
try {
await bulkDeleteFavoriteItems(activeFavoriteFolderId.value, ids);
if (!isCurrentFavoriteRequest(generation)) return;
favoriteItemsByFolder.value = {
...favoriteItemsByFolder.value,
[activeFavoriteFolderId.value]: (
favoriteItemsByFolder.value[activeFavoriteFolderId.value] ?? []
).filter((favorite) => !ids.includes(favorite.id)),
};
await refreshFavorites();
} catch (error: unknown) {
if (!isCurrentFavoriteRequest(generation)) return;
favoriteError.value = (error as Error)?.message || "移除收藏失败";
} finally {
if (isCurrentFavoriteRequest(generation)) favoriteLoading.value = false;
}
};
const installResolvedFavorites = async (items: ResolvedFavoriteItem[]) => {
for (const item of items) {
if (item.selectedApp) {
await onDetailInstallRef(item.selectedApp);
}
}
};
const clearFavoriteState = () => {
favoriteRequestGeneration.value += 1;
favoriteFolders.value = [];
activeFavoriteFolderId.value = null;
favoriteItems.value = [];
favoriteItemsByFolder.value = {};
showFavoriteSelector.value = false;
favoriteTargetApp.value = null;
favoriteSelectorDraftFolderIds.value = null;
favoriteLoading.value = false;
favoriteError.value = "";
};
// requireLogin / onDetailInstall 分别定义在 useAccountSync / useDownloads
// 通过模块级可赋值引用注入,避免循环依赖。
let requireLoginRef: (message: string) => boolean = () => true;
export const registerRequireLogin = (fn: (message: string) => boolean) => {
requireLoginRef = fn;
};
let onDetailInstallRef: (app: App) => Promise<void> = async () => undefined;
export const registerOnDetailInstall = (fn: (app: App) => Promise<void>) => {
onDetailInstallRef = fn;
};
// 这些 computed 依赖 currentDisplayApp / favorite 状态,放在本模块汇总派生。
export const currentFavoriteMetadata = computed(
(): {
favorited: boolean;
folderName: string;
} => {
// 延迟引用 currentDisplayApp,避免顶层循环导入时的初始化顺序问题
const app = currentDisplayAppRef();
if (!app) return { favorited: false, folderName: "" };
const folder = favoriteFolders.value.find((favoriteFolder) => {
const items = favoriteItemsByFolder.value[favoriteFolder.id] ?? [];
return items.some(
(favorite) =>
favorite.pkgname === app.pkgname &&
favorite.category === app.category,
);
});
if (!folder) return { favorited: false, folderName: "" };
return { favorited: true, folderName: folder.name.trim() };
},
);
export const currentFavoriteFolderIds = computed(
(): Array<number | "default"> => {
if (favoriteSelectorDraftFolderIds.value) {
return favoriteSelectorDraftFolderIds.value;
}
const app = favoriteTargetApp.value ?? currentDisplayAppRef();
if (!app) return [];
return favoriteFolders.value
.filter((folder) =>
(favoriteItemsByFolder.value[folder.id] ?? []).some(
(favorite) =>
favorite.pkgname === app.pkgname &&
favorite.category === app.category,
),
)
.map((folder) => folder.id);
},
);
export const resolvedFavoriteItems = computed<ResolvedFavoriteItem[]>(() =>
resolveFavoriteItems(
favoriteItems.value,
apps.value,
installedApps.value,
availableSources.value,
storeFilter.value,
clientArch.value,
),
);
// currentDisplayApp 由 useAppDetail 持有,此处通过注入引用获取,避免循环导入
let currentDisplayAppRef: () => App | null = () => null;
export const registerCurrentDisplayApp = (fn: () => App | null) => {
currentDisplayAppRef = fn;
};
export {
loadFavoriteFolders,
loadActiveFavoriteItems,
loadAllFavoriteItems,
loadFavoriteMetadataForDetail,
refreshFavorites,
openFavoriteSelector,
toFavoritePayload,
saveCurrentFavoriteFolders,
createFavoriteFolderFromSelector,
openFavoriteManagement,
selectFavoriteFolder,
createFavoriteFolderFromPrompt,
removeSelectedFavorites,
installResolvedFavorites,
clearFavoriteState,
nextFavoriteRequestGeneration,
isCurrentFavoriteRequest,
};
+100
View File
@@ -0,0 +1,100 @@
/**
* useHttp HTTP axios + +
*
* App.vue composable
* 穿createCacheBusterInterceptor
*/
import axios, { AxiosError } from "axios";
import { APM_STORE_BASE_URL } from "../global/storeConfig";
import { createCacheBusterInterceptor } from "../global/cacheBusterInterceptor";
// Axios 全局配置
export const axiosInstance = axios.create({
baseURL: APM_STORE_BASE_URL,
timeout: 5000, // 增加到 5 秒,避免网络波动导致的超时
});
// C2:数据 JSONapplist / categories / sidebar-config 等)追加 ?_t 版本戳,
// 穿透 CDN 边缘缓存,确保返回最新列表。复用共享缓存穿透拦截器(带 TTL 复用戳,
// 避免同会话频繁击穿缓存)。与主进程 onBeforeSendHeaders 注入的 no-cache 互为兜底。
// 这是有意的缓存击穿策略(非缺陷),用于解决商店目录强缓存导致的更新延迟。
axiosInstance.interceptors.request.use(createCacheBusterInterceptor());
// 5xx / 网络错误 / 超时重试;4xx(如 404)快速失败
const RETRYABLE_STATUS = new Set([502, 503, 504]);
// 计算重试延迟:优先尊重服务端 Retry-After 头;429 限流用指数退避(封顶 30s);
// 其余 5xx 用固定延迟。避免对限流场景频繁重试浪费带宽。
const calculateRetryDelay = (
status: number | undefined,
attempt: number,
retryAfterHeader?: string,
fixedDelayMs = 500,
): number => {
if (retryAfterHeader) {
const seconds = Number.parseInt(retryAfterHeader, 10);
if (!Number.isNaN(seconds) && seconds > 0) return seconds * 1000;
}
if (status === 429) {
return Math.min(1000 * 2 ** attempt, 30000);
}
return fixedDelayMs;
};
export const fetchWithRetry = async <T>(
path: string,
signal?: AbortSignal,
retries = 2,
retryDelayMs = 500,
): Promise<T | null> => {
for (let attempt = 0; attempt <= retries; attempt++) {
try {
const resp = await axiosInstance.get<T>(path, { signal });
return resp.data;
} catch (err) {
const ae = err as AxiosError;
const status = ae.response?.status;
// 4xx(含 404)快速失败,不重试
if (status && status < 500 && status !== 429) {
return null;
}
// 仅对网络错误 / 5xx / 429 重试
const retryable =
!status || RETRYABLE_STATUS.has(status) || status === 429;
if (!retryable || attempt === retries) {
return null;
}
const retryAfter = ae.response?.headers?.["retry-after"] as
| string
| undefined;
const delay = calculateRetryDelay(
status,
attempt,
retryAfter,
retryDelayMs,
);
await new Promise((r) => setTimeout(r, delay));
}
}
return null;
};
// 全局请求取消控制器(目录加载期间可整体取消)
export const rootAbortController = new AbortController();
// 给 Promise 包一层超时,超时即 reject。finally 中清理定时器,避免泄漏。
export const withTimeout = async <T>(
promise: Promise<T>,
ms: number,
label = "operation",
): Promise<T> => {
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(`${label} 超时 (${ms}ms)`)), ms);
});
try {
return await Promise.race([promise, timeout]);
} finally {
if (timer) clearTimeout(timer);
}
};
+367
View File
@@ -0,0 +1,367 @@
/**
* useInstalledApps
*
* App.vue isInstalledAppInfo / LIST_INSTALLED_TIMEOUT_MS /
* resolveInstalledOrigins / refreshInstalledApps / mapInstalledAppToCatalogApp /
* refreshFavoriteInstalledApps / requestUninstall / openInstalledModal /
* closeInstalledModal / uninstallInstalledApp / installedCloudKeys /
* installedCloudPackageKeys
*
* useAppStatewithTimeout / rootAbortController useHttp
*/
import { computed } from "vue";
import type { App, InstalledAppInfo } from "../global/typedefinition";
import {
apps,
installedApps,
installedLoading,
installedError,
installedWarning,
installedRefreshGeneration,
showInstalledModal,
sparkAvailable,
apmAvailable,
storeFilter,
showUninstallModal,
uninstallTargetApp,
syncCandidateApps,
availableSources,
} from "./useAppState";
import { withTimeout } from "./useHttp";
import {
isOriginUsable,
isOriginEnabled,
getEffectiveStoreFilter,
} from "../modules/storeFilter";
import { cloudItemKey, cloudPackageKey } from "../modules/appListSync";
import { removeDownloadItem } from "../global/downloadStatus";
const isInstalledAppInfo = (value: unknown): value is InstalledAppInfo => {
if (!value || typeof value !== "object") return false;
const v = value as Record<string, unknown>;
return typeof v.pkgname === "string" && typeof v.origin === "string";
};
const LIST_INSTALLED_TIMEOUT_MS = 15000;
// 根据当前启动模式和系统可用性,确定需要查询哪些 origin 的已安装应用
const resolveInstalledOrigins = (): Array<"spark" | "apm"> => {
const origins: Array<"spark" | "apm"> = [];
if (isOriginUsable(storeFilter.value, "spark", availableSources.value)) {
origins.push("spark");
}
if (isOriginUsable(storeFilter.value, "apm", availableSources.value)) {
origins.push("apm");
}
return origins;
};
const refreshInstalledApps = async () => {
// 异步竞态防护:进入时若模态框已关闭(极小概率),直接放弃本轮请求
if (!showInstalledModal.value) return;
installedLoading.value = true;
installedError.value = "";
installedWarning.value = "";
// 代次计数器:每次进入自增;await 之后若代次已变(新一轮刷新 / 关闭重开),丢弃本轮结果
const generation = ++installedRefreshGeneration.value;
try {
const origins = resolveInstalledOrigins();
// Spark 已安装列表依赖商店目录(apps.value)来枚举包名。
// 仅当目录中存在可枚举的 Spark 包时才查询 Spark
// 否则空目录会触发"全量扫描整个系统"的陷阱导致列表被全部跳过而误报为空。
const sparkPkgnameList = apps.value
.filter((a) => a.origin === "spark")
.map((a) => a.pkgname);
const effectiveOrigins = origins.filter(
(o) => o !== "spark" || sparkPkgnameList.length > 0,
);
if (effectiveOrigins.length === 0) {
installedApps.value = [];
// 目录尚未加载完成(但来源可用)时给出过渡提示,待目录加载后会自动重查
installedError.value =
apps.value.length === 0
? "正在加载应用目录,请稍候…"
: "当前系统不可用应用管理功能";
return;
}
// 并行查询每个 origin 的已安装应用:
// 用 allSettled + 每源超时,避免单一来源(如 APM)响应慢/挂起阻塞整体;
// 超时或失败的来源在下方循环标记为 failedOrigin,不影响其它来源结果。
const results = await Promise.allSettled(
effectiveOrigins.map((origin) =>
withTimeout(
window.ipcRenderer.invoke("list-installed", {
origin,
pkgnameList: origin === "spark" ? sparkPkgnameList : undefined,
}),
LIST_INSTALLED_TIMEOUT_MS,
`${origin} list-installed`,
),
),
);
// 异步结束后的回调阶段重新校验代次与模态可见性,避免陈旧竞态写入 ref
if (generation !== installedRefreshGeneration.value) return;
if (!showInstalledModal.value) return;
const combinedApps: App[] = [];
// 同一 pkgname 可能同时以 APM 与 Spark 两种来源安装,这里聚合其来源集合
const originsByPkg = new Map<string, Set<"spark" | "apm">>();
const failedOrigins: string[] = [];
for (let i = 0; i < effectiveOrigins.length; i++) {
const origin = effectiveOrigins[i];
const settled = results[i];
// allSettled: rejected(超时/异常)或 success=false 都视为该来源失败
if (settled.status !== "fulfilled" || !settled.value?.success) {
failedOrigins.push(origin);
continue;
}
const result = settled.value;
const appList = Array.isArray(result?.apps) ? result.apps : [];
for (const rawApp of appList) {
// 运行时类型守卫,避免后端字段缺失造成下游访问 undefined 抛出
if (!isInstalledAppInfo(rawApp)) continue;
const app = rawApp;
// Find matching remote app to enrich data. We look exactly for that origin.
let appInfo = apps.value.find(
(a) => a.pkgname === app.pkgname && a.origin === origin,
);
if (origin === "spark" && !appInfo) {
// Only show Spark packages that exist in the App Store catalogue
continue;
}
if (appInfo) {
appInfo.flags = app.flags;
appInfo.arch = app.arch;
appInfo.currentStatus = "installed";
appInfo.isDependency = app.isDependency;
} else {
// 如果在当前应用列表中找不到该应用,创建一个最小的 App 对象
appInfo = {
name: app.name || app.pkgname,
pkgname: app.pkgname,
version: app.version,
category: "unknown",
tags: "",
more: "",
filename: "",
torrent_address: "",
author: "",
contributor: "",
website: "",
update: "",
size: "",
img_urls: [],
icons: app.icon || "",
origin: app.origin || (app.arch?.includes("apm") ? "apm" : "spark"),
currentStatus: "installed",
arch: app.arch,
flags: app.flags,
isDependency: app.isDependency,
};
}
// 合并同一 pkgname 在多个来源的安装记录为单条,避免列表出现重复项(相同 pkgname 键冲突)
const existingIdx = combinedApps.findIndex(
(a) => a.pkgname === appInfo.pkgname,
);
if (existingIdx === -1) {
combinedApps.push(appInfo);
}
// 记录该 pkgname 当前这一来源,供卸载时判断走 APM 还是 Spark
const originSet =
originsByPkg.get(appInfo.pkgname) ?? new Set<"spark" | "apm">();
originSet.add(origin);
originsByPkg.set(appInfo.pkgname, originSet);
}
}
// 将来源集合回写到每条已安装应用,供卸载时判断应走 APM 还是 Spark
for (const app of combinedApps) {
const set = originsByPkg.get(app.pkgname);
if (set) app.origins = Array.from(set);
}
installedApps.value = combinedApps;
// 部分来源失败使用轻量 warning(不与列表同时呈现红色致命错误条),致命/全失败仍用 error
if (failedOrigins.length > 0) {
const labels = failedOrigins
.map((o) => (o === "spark" ? "Spark" : "APM"))
.join("、");
if (combinedApps.length > 0) {
installedWarning.value = `部分来源加载失败(${labels}),已安装列表可能不完整`;
} else {
installedError.value = `读取${labels}已安装应用失败`;
}
}
} catch (error: unknown) {
if (generation !== installedRefreshGeneration.value) return;
if (!showInstalledModal.value) return;
installedApps.value = [];
installedError.value = (error as Error)?.message || "读取已安装应用失败";
} finally {
// 仅最末一代次负责清理 loading,防止陈旧代次提前关闭 loading 影响后续刷新
if (generation === installedRefreshGeneration.value) {
installedLoading.value = false;
}
}
};
const mapInstalledAppToCatalogApp = (
app: InstalledAppInfo,
origin: "spark" | "apm",
): App | null => {
const appInfo = apps.value.find(
(catalogApp) =>
catalogApp.pkgname === app.pkgname && catalogApp.origin === origin,
);
if (origin === "spark" && !appInfo) {
return null;
}
if (appInfo) {
appInfo.flags = app.flags;
appInfo.arch = app.arch;
appInfo.currentStatus = "installed";
appInfo.isDependency = app.isDependency;
return appInfo;
}
return {
name: app.name || app.pkgname,
pkgname: app.pkgname,
version: app.version,
category: "unknown",
tags: "",
more: "",
filename: "",
torrent_address: "",
author: "",
contributor: "",
website: "",
update: "",
size: "",
img_urls: [],
icons: app.icon || "",
origin: app.origin || (app.arch?.includes("apm") ? "apm" : "spark"),
currentStatus: "installed",
arch: app.arch,
flags: app.flags,
isDependency: app.isDependency,
};
};
const refreshFavoriteInstalledApps = async (): Promise<void> => {
const origins: Array<"spark" | "apm"> = [];
if (isOriginEnabled(storeFilter.value, "spark") && sparkAvailable.value) {
origins.push("spark");
}
if (isOriginEnabled(storeFilter.value, "apm") && apmAvailable.value) {
origins.push("apm");
}
const refreshedApps: App[] = [];
await Promise.all(
origins.map(async (origin) => {
const pkgnameList =
origin === "spark"
? apps.value
.filter((app) => app.origin === "spark")
.map((app) => app.pkgname)
: undefined;
const result = await window.ipcRenderer.invoke("list-installed", {
origin,
pkgnameList,
});
if (!result?.success) return;
const appList = Array.isArray(result?.apps) ? result.apps : [];
for (const rawApp of appList) {
// 运行时类型守卫:避免后端字段缺失时下游访问 undefined
if (!isInstalledAppInfo(rawApp)) continue;
const appInfo = mapInstalledAppToCatalogApp(rawApp, origin);
if (appInfo) refreshedApps.push(appInfo);
}
}),
);
const refreshedKeys = new Set(
refreshedApps.map((app) => `${app.origin}:${app.pkgname}`),
);
installedApps.value = [
...installedApps.value.filter(
(app) =>
!origins.includes(app.origin) &&
!refreshedKeys.has(`${app.origin}:${app.pkgname}`),
),
...refreshedApps,
];
};
const requestUninstall = (app: App) => {
uninstallTargetApp.value = app;
showUninstallModal.value = true;
removeDownloadItem(app.pkgname);
};
const openInstalledModal = () => {
if (
getEffectiveStoreFilter(storeFilter.value, availableSources.value) === null
) {
return;
}
showInstalledModal.value = true;
refreshInstalledApps();
};
const closeInstalledModal = () => {
showInstalledModal.value = false;
// 关闭模态框时同步清空错误 / 警告,避免下次打开时残留过期状态
installedError.value = "";
installedWarning.value = "";
};
const uninstallInstalledApp = (app: App) => {
requestUninstall(app);
};
// 应用在更新中心/详情页的安装状态变更后,刷新已安装列表(由 App.vue 编排调用)
const onUninstallSuccess = () => {
if (showInstalledModal.value) {
refreshInstalledApps();
}
};
// 已安装应用与云端同步候选用 key 集合(供恢复模态判断已装项)
const installedCloudKeys = computed(
() => new Set(installedApps.value.map((app) => cloudItemKey(app))),
);
const installedCloudPackageKeys = computed(
() => new Set(syncCandidateApps.value.map((app) => cloudPackageKey(app))),
);
export {
isInstalledAppInfo,
LIST_INSTALLED_TIMEOUT_MS,
resolveInstalledOrigins,
refreshInstalledApps,
mapInstalledAppToCatalogApp,
refreshFavoriteInstalledApps,
requestUninstall,
openInstalledModal,
closeInstalledModal,
uninstallInstalledApp,
onUninstallSuccess,
installedCloudKeys,
installedCloudPackageKeys,
};
+218
View File
@@ -0,0 +1,218 @@
/**
* useRanking links +
*
* App.vue loadHome / loadRanking / fetchDownloadCount / /
* publishRanking
*
* apps / homeLinks / apmRanking / sparkRanking / rankingLoading /
* homeLoading / homeError / storeFilter useAppState
*/
import type { App, HomeLink } from "../global/typedefinition";
import {
apps,
homeLinks,
homeLoading,
homeError,
apmRanking,
sparkRanking,
rankingLoading,
storeFilter,
} from "./useAppState";
import { rootAbortController } from "./useHttp";
import { APM_STORE_BASE_URL } from "../global/storeConfig";
const DOWNLOAD_COUNT_CACHE_KEY = "spark-store:download-counts:v1";
const DOWNLOAD_COUNT_CACHE_TTL_MS = 60 * 60 * 1000; // 1 小时
interface CachedCount {
count: number;
ts: number;
}
const downloadCountCache = new Map<string, CachedCount>();
const cacheKey = (app: App) => `${app.origin}:${app.category}:${app.pkgname}`;
const loadCacheFromStorage = () => {
try {
const raw = localStorage.getItem(DOWNLOAD_COUNT_CACHE_KEY);
if (!raw) return;
const data = JSON.parse(raw) as Record<string, CachedCount>;
const now = Date.now();
for (const [k, v] of Object.entries(data)) {
if (now - v.ts < DOWNLOAD_COUNT_CACHE_TTL_MS) {
downloadCountCache.set(k, v);
}
}
} catch {
// ignore corrupted cache
}
};
const saveCacheToStorage = () => {
try {
const obj: Record<string, CachedCount> = {};
downloadCountCache.forEach((v, k) => {
obj[k] = v;
});
localStorage.setItem(DOWNLOAD_COUNT_CACHE_KEY, JSON.stringify(obj));
} catch {
// ignore quota errors
}
};
const fetchDownloadCount = async (app: App): Promise<number> => {
const key = cacheKey(app);
const cached = downloadCountCache.get(key);
if (cached) return cached.count;
const arch = window.apm_store.arch || "amd64";
const finalArch = app.origin === "spark" ? `${arch}-store` : `${arch}-apm`;
try {
const resp = await fetch(
`${APM_STORE_BASE_URL}/${finalArch}/${app.category}/${app.pkgname}/download-times.txt`,
{ signal: rootAbortController.signal },
);
if (!resp.ok) return 0;
const text = (await resp.text()).trim();
const n = parseInt(text, 10);
const count = Number.isFinite(n) ? n : 0;
downloadCountCache.set(key, { count, ts: Date.now() });
return count;
} catch {
return 0;
}
};
const publishRanking = (results: App[]) => {
apmRanking.value = results
.filter((a) => a.origin === "apm")
.sort((x, y) => (y.downloadCount || 0) - (x.downloadCount || 0))
.slice(0, 10);
sparkRanking.value = results
.filter((a) => a.origin === "spark")
.sort((x, y) => (y.downloadCount || 0) - (x.downloadCount || 0))
.slice(0, 10);
};
let rankingGeneration = 0;
const loadRanking = async () => {
if (apps.value.length === 0) return;
const gen = ++rankingGeneration;
rankingLoading.value = true;
const all = apps.value.slice();
const CONCURRENCY = 15;
const results: App[] = [];
for (let i = 0; i < all.length; i += CONCURRENCY) {
if (gen !== rankingGeneration) {
saveCacheToStorage();
return;
}
const batch = all.slice(i, i + CONCURRENCY);
const settled = await Promise.all(
batch.map(async (app) => ({
...app,
downloadCount: await fetchDownloadCount(app),
})),
);
results.push(...settled);
// 边拉边发:每批完成后立即发布增量排名
if (gen === rankingGeneration) publishRanking(results);
}
if (gen !== rankingGeneration) {
saveCacheToStorage();
return;
}
rankingLoading.value = false;
saveCacheToStorage();
};
// 启动时即加载本地缓存(无需等待 apps),二次启动首屏即可见缓存排行
loadCacheFromStorage();
// 排行榜在 loadApps 全量完成后(onMounted)触发一次,确保 spark/apm 应用均已就绪
const loadHome = async () => {
homeLoading.value = true;
homeError.value = "";
homeLinks.value = [];
try {
const arch = window.apm_store.arch || "amd64";
const modes: Array<"spark" | "apm"> =
storeFilter.value === "both" ? ["spark", "apm"] : [storeFilter.value];
// 按名称去重,spark 优先:同名链接 spark 覆盖 apm
const seenNames = new Set<string>();
// 并行请求各来源的 homelinks.json,缩短首页加载耗时
const modeResults = await Promise.all(
modes.map(async (mode) => {
const finalArch = mode === "spark" ? `${arch}-store` : `${arch}-apm`;
const base = `${APM_STORE_BASE_URL}/${finalArch}/home`;
try {
const res = await fetch(`${base}/homelinks.json`);
if (res.ok) return { mode, raw: (await res.json()) as unknown };
} catch (e) {
console.warn(`Failed to load ${mode} homelinks.json`, e);
}
return { mode, raw: undefined };
}),
);
for (const { mode, raw } of modeResults) {
if (!raw) continue;
// 校验 links 为数组,且每项均为对象(避免后端返回异常结构导致运行时错误)
const links = Array.isArray(raw)
? (raw.filter((x) => x && typeof x === "object") as Record<
string,
unknown
>[])
: [];
for (const l of links) {
// 远程数据不可信,使用 typeof 运行时守卫替代 `as string` 断言,
// 避免非字符串字段(如数字/对象)被注入状态导致下游显示异常。
const name =
typeof l.Name === "string"
? l.Name
: typeof l.name === "string"
? l.name
: "";
if (!name) continue; // 跳过空名称,避免空字符串污染 seenNames 与去重逻辑
if (seenNames.has(name)) continue; // 已由更高优先级来源(spark)占据
// 仅校验 url 必需;远程 homelinks.json 不含 icon 字段(图片由 imgUrl 提供),
// 故 icon 不作为硬性校验,缺省为空串以兼容 HomeLink 类型。
const url =
typeof l.Url === "string"
? l.Url
: typeof l.url === "string"
? l.url
: "";
if (!url) continue;
const icon =
typeof l.Icon === "string"
? l.Icon
: typeof l.icon === "string"
? l.icon
: "";
seenNames.add(name);
// 显式提取已知字段构造,避免通过展开运算符 { ...l } 把远程不可信数据中的未知属性注入响应式状态
const safeLink: HomeLink = {
name,
url,
icon,
more: typeof l.more === "string" ? l.more : undefined,
imgUrl: typeof l.imgUrl === "string" ? l.imgUrl : undefined,
type: typeof l.type === "string" ? l.type : undefined,
origin: mode,
};
homeLinks.value.push(safeLink);
}
}
} catch (error: unknown) {
homeError.value = (error as Error)?.message || "加载首页失败";
} finally {
homeLoading.value = false;
}
};
export { loadHome, loadRanking };
+33
View File
@@ -0,0 +1,33 @@
import type { InternalAxiosRequestConfig } from "axios";
/**
* 穿 .json GET ?_t=${timestamp}
* 穿 CDN/"上架后搜不到"
*
*
* TTL ttlMs
* 5 "及时感知新数据""不过度击穿缓存"
*
* 穿
*/
export function createCacheBusterInterceptor(ttlMs = 5 * 60 * 1000) {
let cachedTimestamp: number | null = null;
return (config: InternalAxiosRequestConfig): InternalAxiosRequestConfig => {
if (
config.method?.toLowerCase() === "get" &&
typeof config.url === "string"
) {
const reqPath = config.url.split("?")[0];
if (reqPath.endsWith(".json")) {
const now = Date.now();
if (cachedTimestamp === null || now - cachedTimestamp > ttlMs) {
cachedTimestamp = now;
}
const sep = config.url.includes("?") ? "&" : "?";
config.url = `${config.url}${sep}_t=${cachedTimestamp}`;
}
}
return config;
};
}
+76
View File
@@ -0,0 +1,76 @@
/**
* Electron webContents.setZoomFactor
*
* Electron setZoomFactor
* /
*
*
* setZoomFactor
* IPC
*/
export type UiScaleOption = "90" | "100" | "110" | "125" | "150";
// 档位 → 缩放系数(与 Electron setZoomFactor 一致,1 = 100%
const UI_SCALE_MAP: Record<UiScaleOption, number> = {
"90": 0.9,
"100": 1,
"110": 1.1,
"125": 1.25,
"150": 1.5,
};
export const UI_SCALE_OPTIONS: Array<{
value: UiScaleOption;
label: string;
}> = [
{ value: "90", label: "90%" },
{ value: "100", label: "100%" },
{ value: "110", label: "110%" },
{ value: "125", label: "125%" },
{ value: "150", label: "150%" },
];
const UI_SCALE_STORAGE_KEY = "spark-store-ui-scale";
const DEFAULT_UI_SCALE: UiScaleOption = "100";
const isValidUiScale = (v: unknown): v is UiScaleOption =>
typeof v === "string" && v in UI_SCALE_MAP;
/** 读取持久化的界面缩放档位(无/非法时回退默认「100%」) */
export const getUiScale = (): UiScaleOption => {
try {
const raw = localStorage.getItem(UI_SCALE_STORAGE_KEY);
if (isValidUiScale(raw)) return raw;
} catch {
// localStorage 不可用时忽略,使用默认
}
return DEFAULT_UI_SCALE;
};
/** 将档位换算为 Electron 缩放系数 */
export const uiScaleToFactor = (option: UiScaleOption): number =>
UI_SCALE_MAP[option] ?? UI_SCALE_MAP[DEFAULT_UI_SCALE];
/** 选择并持久化界面缩放档位(不在此直接调用主进程,由调用方经 IPC 应用) */
export const setUiScale = (option: UiScaleOption): void => {
try {
localStorage.setItem(UI_SCALE_STORAGE_KEY, option);
} catch {
// 持久化失败时仍已在会话内选定,忽略写入错误
}
};
/**
* IPC
* App.vue onMounted createWindow 1
*
*/
export const initUiScale = async (): Promise<void> => {
const factor = uiScaleToFactor(getUiScale());
try {
await window.ipcRenderer.invoke("set-zoom-factor", factor);
} catch {
// 主进程 IPC 不可用时忽略(主进程启动兜底已设为 1)
}
};
+132 -121
View File
@@ -1,9 +1,23 @@
import { ref } from "vue"; import { ref } from "vue";
import axios from "axios";
import type { App, StoreMode } from "./typedefinition"; import type { App, StoreMode } from "./typedefinition";
import { createCacheBusterInterceptor } from "./cacheBusterInterceptor";
export const APM_STORE_BASE_URL: string = export const APM_STORE_BASE_URL: string =
import.meta.env.VITE_APM_STORE_BASE_URL || ""; import.meta.env.VITE_APM_STORE_BASE_URL || "";
// 加载优先级配置专用客户端:与渲染进程其余请求共用同一套 HTTP 客户端,
// 避免 Electron 渲染进程中全局 fetch 失败导致配置始终为空、auto 模式全部回退 APM。
const priorityConfigAxios = axios.create({
baseURL: APM_STORE_BASE_URL,
timeout: 10000,
});
// C2priority-config.json 走独立 axios 实例(不经过 axiosInstance),
// 复用共享缓存穿透拦截器(带 TTL 复用戳,避免同会话频繁击穿缓存)。
// 与 C1axiosInstance 的拦截器)为同一工厂生成,确保穿透策略与 TTL 行为一致。
priorityConfigAxios.interceptors.request.use(createCacheBusterInterceptor());
export const APM_STORE_STATS_BASE_URL: string = export const APM_STORE_STATS_BASE_URL: string =
import.meta.env.VITE_APM_STORE_STATS_BASE_URL || ""; import.meta.env.VITE_APM_STORE_STATS_BASE_URL || "";
@@ -29,7 +43,10 @@ export const showApmInstallDialog = ref(false);
export const currentStoreMode = ref<StoreMode>("hybrid"); export const currentStoreMode = ref<StoreMode>("hybrid");
// 混合模式下默认优先安装的来源(当没有服务器配置或配置获取失败时使用) // 混合模式下默认优先安装的来源(当没有服务器配置或配置获取失败时使用)
// 设计意图:社区版当前主推 APM 来源(星火 APM 为新架构、deb 为传统来源),
// 故默认优先级设为 "apm"。非临时回归,属既定产品方向;如后续调整需产品确认。
// TODO: 产品确认默认 APM 优先是否长期保持。
export const HYBRID_DEFAULT_PRIORITY: "apm" | "spark" = "apm"; export const HYBRID_DEFAULT_PRIORITY: "apm" | "spark" = "apm";
// 优先级规则配置接口 // 优先级规则配置接口
@@ -62,14 +79,6 @@ export let dynamicPriorityConfig: PriorityRules = {
}, },
}; };
// 标记是否已从服务器加载配置
export let isPriorityConfigLoaded = false;
// 标记服务器上是否存在配置文件
// false = 没有配置文件,默认优先 Spark
// true = 有配置文件,按配置规则判断(配置为空则默认优先 APM)
export let hasPriorityConfigFile = false;
/** /**
* *
* 配置文件路径: ${arch}-store/priority-config.json ( spark ) * 配置文件路径: ${arch}-store/priority-config.json ( spark )
@@ -77,66 +86,71 @@ export let hasPriorityConfigFile = false;
*/ */
export async function loadPriorityConfig(arch: string): Promise<void> { export async function loadPriorityConfig(arch: string): Promise<void> {
try { try {
const configUrl = `${APM_STORE_BASE_URL}/${arch}-store/priority-config.json`; const configPath = `/${arch}-store/priority-config.json`;
const response = await fetch(configUrl, { console.log(
method: "GET", `[PriorityConfig] 开始加载优先级配置: ${APM_STORE_BASE_URL}${configPath}`,
cache: "no-cache", );
}); const response = await priorityConfigAxios.get(configPath);
const config = response.data;
if (response.ok) { // 顶层结构校验:畸形数据(非对象/为数组/null)直接走失败回退,避免访问属性抛错
const config = await response.json(); if (!config || typeof config !== "object" || Array.isArray(config)) {
hasPriorityConfigFile = true; throw new Error("Invalid priority-config format: expected object");
// 支持新旧两种配置格式
if (config.sparkPriority || config.apmPriority) {
// 新格式:双向配置
dynamicPriorityConfig = {
sparkPriority: {
pkgnames: config.sparkPriority?.pkgnames || [],
categories: config.sparkPriority?.categories || [],
tags: config.sparkPriority?.tags || [],
},
apmPriority: {
pkgnames: config.apmPriority?.pkgnames || [],
categories: config.apmPriority?.categories || [],
tags: config.apmPriority?.tags || [],
},
};
} else {
// 旧格式:只配置 sparkPriority(兼容旧配置)
dynamicPriorityConfig = {
sparkPriority: {
pkgnames: config.pkgnames || [],
categories: config.categories || [],
tags: config.tags || [],
},
apmPriority: {
pkgnames: [],
categories: [],
tags: [],
},
};
}
isPriorityConfigLoaded = true;
console.log(
"[PriorityConfig] 已从服务器加载优先级配置:",
dynamicPriorityConfig,
);
} else {
// 配置文件不存在,默认优先 Spark
console.log("[PriorityConfig] 服务器无配置文件,使用默认 Spark 优先");
hasPriorityConfigFile = false;
resetPriorityConfig();
} }
// 支持新旧两种配置格式
if (config.sparkPriority || config.apmPriority) {
// 新格式:双向配置
dynamicPriorityConfig = {
sparkPriority: {
pkgnames: config.sparkPriority?.pkgnames || [],
categories: config.sparkPriority?.categories || [],
tags: config.sparkPriority?.tags || [],
},
apmPriority: {
pkgnames: config.apmPriority?.pkgnames || [],
categories: config.apmPriority?.categories || [],
tags: config.apmPriority?.tags || [],
},
};
} else {
// 旧格式:只配置 sparkPriority(兼容旧配置)
dynamicPriorityConfig = {
sparkPriority: {
pkgnames: config.pkgnames || [],
categories: config.categories || [],
tags: config.tags || [],
},
apmPriority: {
pkgnames: [],
categories: [],
tags: [],
},
};
}
// 仅打印规则计数而非完整配置,避免潜在敏感信息泄露(诊断用)
const ruleCount = (r: {
pkgnames: string[];
categories: string[];
tags: string[];
}) => r.pkgnames.length + r.categories.length + r.tags.length;
console.log(
`[PriorityConfig] 已从服务器加载优先级配置: spark ${ruleCount(
dynamicPriorityConfig.sparkPriority,
)} , apm ${ruleCount(dynamicPriorityConfig.apmPriority)} `,
);
} catch (error) { } catch (error) {
// 获取失败,默认优先 Spark // 获取失败(含 404:服务器无配置文件),默认优先 APM。
console.warn("[PriorityConfig] 加载配置失败,使用默认 Spark 优先:", error); // 注:此处为配置缺失/加载失败,dynamicPriorityConfig 重置为空规则,
hasPriorityConfigFile = false; // 由 getHybridDefaultOrigin 回退 HYBRID_DEFAULT_PRIORITY(默认 APM)。
console.warn(
`[PriorityConfig] 加载配置失败(${APM_STORE_BASE_URL}/${arch}-store/priority-config.json),使用默认 APM 优先:`,
error,
);
resetPriorityConfig(); resetPriorityConfig();
} }
} }
/** /**
* * /
*/ */
function resetPriorityConfig(): void { function resetPriorityConfig(): void {
dynamicPriorityConfig = { dynamicPriorityConfig = {
@@ -151,73 +165,70 @@ function resetPriorityConfig(): void {
tags: [], tags: [],
}, },
}; };
isPriorityConfigLoaded = true;
} }
/** /**
* *
* * "apm" / "spark" null
* 1. apmPriority.pkgnames - APM * apmPriority.pkgnames > sparkPriority.pkgnames >
* 2. sparkPriority.pkgnames - Spark * apmPriority.categories > sparkPriority.categories > apmPriority.tags > sparkPriority.tags
* 3. apmPriority.categories - APM */
* 4. sparkPriority.categories - Spark function matchPriority(app: App): "apm" | "spark" | null {
* 5. apmPriority.tags - APM const { sparkPriority, apmPriority } = dynamicPriorityConfig;
* 6. sparkPriority.tags - Spark
* 7. if (apmPriority.pkgnames.includes(app.pkgname)) return "apm";
* - Spark if (sparkPriority.pkgnames.includes(app.pkgname)) return "spark";
* - APM if (apmPriority.categories.includes(app.category)) return "apm";
if (sparkPriority.categories.includes(app.category)) return "spark";
if (app.tags && apmPriority.tags.length > 0) {
const appTags = app.tags.split(";").map((t) => t.trim().toLowerCase());
for (const ruleTag of apmPriority.tags) {
if (appTags.includes(ruleTag.toLowerCase())) return "apm";
}
}
if (app.tags && sparkPriority.tags.length > 0) {
const appTags = app.tags.split(";").map((t) => t.trim().toLowerCase());
for (const ruleTag of sparkPriority.tags) {
if (appTags.includes(ruleTag.toLowerCase())) return "spark";
}
}
return null;
}
/**
*
* sparkAppapmApp
* pkgname
* apmPriority apm
* 退 HYBRID_DEFAULT_PRIORITY APM
* @param app * @param app
* @returns "apm" "spark" * @returns "apm" "spark"
*/ */
export function getHybridDefaultOrigin(app: App): "apm" | "spark" { export function getHybridDefaultOrigin(app: App): "apm" | "spark" {
const { sparkPriority, apmPriority } = dynamicPriorityConfig; const result = matchPriority(app);
if (result) return result;
// 1. 检查 APM 优先的包名(最高优先级) // 合并应用:两个子版分别匹配,任一命中即采用。
if (apmPriority.pkgnames.includes(app.pkgname)) { // 若子版自身 category 为空(如部分入口构造的 fallback 应用),
return "apm"; // 回退使用顶层 app.category 参与分类规则匹配,避免漏匹配。
if (app.isMerged) {
const fallbackCategory = app.category;
// 构造 category 回退顶层的候选项,避免子版 category 为空时分类规则漏匹配
const toCandidate = (sub: App): App =>
sub.category ? sub : { ...sub, category: fallbackCategory };
const sparkHit = app.sparkApp
? matchPriority(toCandidate(app.sparkApp))
: null;
const apmHit = app.apmApp ? matchPriority(toCandidate(app.apmApp)) : null;
// apmPriority 是「例外优先于 sparkPriority」:当两子版方向相反命中
//sparkApp 命中 spark 且 apmApp 命中 apm)时,按配置语义让 apm 胜出。
if (sparkHit === "spark" && apmHit === "apm") return "apm";
if (sparkHit) return sparkHit;
if (apmHit) return apmHit;
} }
// 2. 检查 Spark 优先的包名 // 默认行为:与 HYBRID_DEFAULT_PRIORITY 保持一致(默认优先 APM)
if (sparkPriority.pkgnames.includes(app.pkgname)) { return HYBRID_DEFAULT_PRIORITY;
return "spark";
}
// 3. 检查 APM 优先的分类
if (apmPriority.categories.includes(app.category)) {
return "apm";
}
// 4. 检查 Spark 优先的分类
if (sparkPriority.categories.includes(app.category)) {
return "spark";
}
// 5. 检查 APM 优先的标签
if (app.tags && apmPriority.tags.length > 0) {
const appTags = app.tags.split(";").map((t) => t.trim().toLowerCase());
for (const ruleTag of apmPriority.tags) {
if (appTags.includes(ruleTag.toLowerCase())) {
return "apm";
}
}
}
// 6. 检查 Spark 优先的标签
if (app.tags && sparkPriority.tags.length > 0) {
const appTags = app.tags.split(";").map((t) => t.trim().toLowerCase());
for (const ruleTag of sparkPriority.tags) {
if (appTags.includes(ruleTag.toLowerCase())) {
return "spark";
}
}
}
// 7. 默认行为
if (!hasPriorityConfigFile) {
// 没有配置文件 → 默认优先 Spark
return "spark";
} else {
// 有配置文件但规则为空 → 默认优先 APM
return "apm";
}
} }
+67
View File
@@ -0,0 +1,67 @@
// 标签优先显示策略
//
// 用于在「应用详情页」决定进入时默认展示的来源(Spark / APM)标签。
// 该配置为纯前端显示偏好(选中的标签即安装时对应的来源),不涉及后端安装脚本,
// 因此使用 localStorage 持久化并通过共享 ref 在设置页与详情页之间共享。
//
// 策略取值:
// - "auto" : 自动选择 —— 按应用配置的优先级(getHybridDefaultOrigin)自动选中
// - "spark" : Spark 优先 —— 默认选中 Spark 标签;若无 Spark 标签则回退优先级策略
// - "apm" : APM 优先 —— 默认选中 APM 标签;若无 APM 标签则回退优先级策略
// 注:当应用仅有一个来源标签时,无论策略如何都默认展示该标签。
// 注:若本地仅安装了某一个来源版本,详情页会优先展开已安装版本,此策略在都安装/都未安装时生效。
import { ref } from "vue";
export type TagPriorityStrategy = "auto" | "spark" | "apm";
const STORAGE_KEY = "spark-store-tag-priority-strategy";
const VALID_STRATEGIES: TagPriorityStrategy[] = ["auto", "spark", "apm"];
// 共享响应式引用:设置页写入、详情页读取,保证跨组件同步
const strategy = ref<TagPriorityStrategy>("auto");
// 导出共享 ref,供详情页以响应式方式 watch(设置变化实时生效)
export const tagPriorityStrategyRef = strategy;
let loaded = false;
const readFromStorage = (): TagPriorityStrategy => {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored && (VALID_STRATEGIES as string[]).includes(stored)) {
return stored as TagPriorityStrategy;
}
} catch {
// localStorage 不可用时静默降级为默认值
}
return "auto";
};
// 首次从持久化读取(仅执行一次),可在应用启动时调用
export const initTagPriorityStrategy = (): void => {
if (loaded) return;
strategy.value = readFromStorage();
loaded = true;
};
// 同步获取当前策略(确保已加载过持久化值)
export const getTagPriorityStrategy = (): TagPriorityStrategy => {
if (!loaded) {
strategy.value = readFromStorage();
loaded = true;
}
return strategy.value;
};
// 设置并持久化策略
export const setTagPriorityStrategy = (value: TagPriorityStrategy): void => {
if (!(VALID_STRATEGIES as string[]).includes(value)) return;
strategy.value = value;
try {
localStorage.setItem(STORAGE_KEY, value);
} catch {
// localStorage 不可用时仅更新内存态,忽略写入失败
}
};
+13 -3
View File
@@ -115,6 +115,9 @@ export interface App {
sparkApp?: App; // Optional reference to the spark version sparkApp?: App; // Optional reference to the spark version
apmApp?: App; // Optional reference to the apm version apmApp?: App; // Optional reference to the apm version
viewingOrigin?: "spark" | "apm"; // Currently viewed origin inside the app modal viewingOrigin?: "spark" | "apm"; // Currently viewed origin inside the app modal
forceViewingOrigin?: boolean; // true 表示 viewingOrigin 为父组件显式指定(如从已安装页按特定来源打开),优先级高于用户标签策略;未设置或 false 时由标签策略决定默认展示
origins?: Array<"spark" | "apm">; // 实际安装来源集合(同时以 APM 与 Spark 安装时含两项)
downloadCount?: number; // 下载量(用于下载排行,由 download-times.txt 解析)
} }
export interface UpdateAppItem { export interface UpdateAppItem {
@@ -153,6 +156,10 @@ export interface UpdateCenterItem {
migrationSource?: UpdateSource; migrationSource?: UpdateSource;
migrationTarget?: UpdateSource; migrationTarget?: UpdateSource;
aptssVersion?: string; aptssVersion?: string;
// 更新发布时间(毫秒时间戳),用于列表显示「X天前」;暂无数据时前端降级为「—」
updateTime?: number;
// 是否被系统锁定(apt-mark hold)。被锁定项默认不可批量选中,需用户单独开启强制安装
held?: boolean;
} }
export interface UpdateCenterTaskState { export interface UpdateCenterTaskState {
@@ -170,6 +177,8 @@ export interface UpdateCenterTaskState {
export interface UpdateCenterStartTask { export interface UpdateCenterStartTask {
taskKey: string; taskKey: string;
id: number; id: number;
// 强制安装被系统锁定(apt-mark hold)的包
forceHeld?: boolean;
} }
export interface UpdateCenterSnapshot { export interface UpdateCenterSnapshot {
@@ -223,18 +232,19 @@ export interface CategoryInfo {
zh: string; zh: string;
origins?: string[]; origins?: string[];
origin?: "spark" | "apm"; origin?: "spark" | "apm";
[k: string]: unknown; // 保留扩展点,避免使用宽泛的索引签名削弱类型安全
extra?: Record<string, unknown>;
} }
export interface HomeLink { export interface HomeLink {
name: string; name: string;
url: string; url: string;
icon: string; // 数据源(homelinks.json)并不提供 icon 字段,图片统一由 imgUrl 提供,故设为可选
icon?: string;
more?: string; more?: string;
imgUrl?: string; imgUrl?: string;
type?: string; type?: string;
origin?: "spark" | "apm"; origin?: "spark" | "apm";
[k: string]: unknown;
} }
export interface SidebarEntry { export interface SidebarEntry {
+19
View File
@@ -219,10 +219,29 @@ window.ipcRenderer.on("install-log", (_event, log: InstallLog) => {
window.ipcRenderer.on("install-complete", (_event, log: DownloadResult) => { window.ipcRenderer.on("install-complete", (_event, log: DownloadResult) => {
const downloadObj = downloads.value.find((d) => d.id === log.id); const downloadObj = downloads.value.find((d) => d.id === log.id);
if (downloadObj) { if (downloadObj) {
// 更新中心来源的任务用「更新」语义,其余(普通安装)用「安装」语义,
// 避免把安装结果误报为「下载完成」。
const isUpdate = (downloadObj.source ?? "").toLowerCase().includes("update");
const doneLabel = isUpdate ? "更新完成" : "安装完成";
const failVerb = isUpdate ? "更新失败" : "安装失败";
if (log.success) { if (log.success) {
downloadObj.status = "completed"; downloadObj.status = "completed";
downloadObj.logs.push({ time: Date.now(), message: doneLabel });
} else { } else {
downloadObj.status = "failed"; downloadObj.status = "failed";
// 将失败原因写入日志,避免 UI 停在"正在获取 Metalink"等中间日志后突兀结束
let reason = "未知错误";
try {
const parsed = JSON.parse(log.message);
reason = parsed?.message || reason;
} catch {
reason = log.message || reason;
}
downloadObj.logs.push({
time: Date.now(),
message: `${failVerb}: ${reason}`,
});
} }
} }
}); });
+96
View File
@@ -0,0 +1,96 @@
/**
* 耀
* - App.vue apmRanking / sparkRanking
* - / 耀 / 耀 `apps`
*/
import type { App } from "../global/typedefinition";
/** 榜单统一条数(需求确认:10 条) */
export const TOP_N = 10;
/** 更新荣耀榜取「最近更新」应用的窗口大小(用于聚合这部分贡献者) */
export const RECENT_UPDATE_WINDOW = 30;
export type AppOrigin = "spark" | "apm";
export interface ContributorRank {
name: string;
count: number;
}
/**
* contributor
* `name<email>` `;` / ``
* `<...>`
*/
export function parseContributors(raw: string | undefined): string[] {
if (!raw || typeof raw !== "string") return [];
const names: string[] = [];
for (const part of raw.split(/[;]/)) {
const trimmed = part.trim();
if (!trimmed) continue;
// 去除 <...> 包裹的邮箱,取显示名;长度 1~50 过滤空字符串/异常超长
const name = trimmed.replace(/<[^>]*>/g, "").trim();
if (name.length > 0 && name.length <= 50) names.push(name);
}
return names;
}
/** 对已过滤来源的应用列表,按 contributor 出现次数计数 */
function countContributors(apps: App[]): Map<string, number> {
const counter = new Map<string, number>();
for (const app of apps) {
for (const name of parseContributors(app.contributor)) {
counter.set(name, (counter.get(name) || 0) + 1);
}
}
return counter;
}
function toRanks(counter: Map<string, number>, topN: number): ContributorRank[] {
return [...counter.entries()]
.map(([name, count]) => ({ name, count }))
.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name))
.slice(0, topN);
}
/** 贡献荣耀榜:按来源统计每个贡献者的上榜应用数,取 Top N */
export function aggregateContributors(
apps: App[],
origin: AppOrigin,
topN: number = TOP_N,
): ContributorRank[] {
const filtered = apps.filter((a) => a.origin === origin);
return toRanks(countContributors(filtered), topN);
}
/** 应用更新排行:按 `update` 字段倒序,取某来源的 Top N */
export function topByUpdate(
apps: App[],
origin: AppOrigin,
topN: number = TOP_N,
): App[] {
// 按 update 字段倒序;后端日期格式可能不严格一致(如 2026-08-10 / 2026/08/10 /
// 带时分秒),用时间戳数字比较比 localeCompare 字符串比较更鲁棒。
// 无效日期 getTime() 为 NaN,统一按 0 处理,避免 NaN 参与比较导致乱序。
const toTime = (s: string | undefined): number => {
const t = new Date(s || "").getTime();
return Number.isFinite(t) ? t : 0;
};
return apps
.filter((a) => a.origin === origin && a.update)
.sort((a, b) => toTime(b.update) - toTime(a.update))
.slice(0, topN);
}
/** 更新荣耀榜:取某来源「最近更新」窗口内的应用,聚合其贡献者,取 Top N */
export function topUpdatedContributors(
apps: App[],
origin: AppOrigin,
recentN: number = RECENT_UPDATE_WINDOW,
topN: number = TOP_N,
): ContributorRank[] {
// topByUpdate 内部已按 origin 过滤,无需再次 filter
const recent = topByUpdate(apps, origin, recentN);
return toRanks(countContributors(recent), topN);
}
+127 -21
View File
@@ -24,10 +24,12 @@ export interface UpdateCenterStore {
showMigrationConfirm: Ref<boolean>; showMigrationConfirm: Ref<boolean>;
searchQuery: Ref<string>; searchQuery: Ref<string>;
selectedTaskKeys: Ref<Set<string>>; selectedTaskKeys: Ref<Set<string>>;
forcedTaskKeys: Ref<Set<string>>;
snapshot: Ref<UpdateCenterSnapshot>; snapshot: Ref<UpdateCenterSnapshot>;
filteredItems: ComputedRef<UpdateCenterItem[]>; filteredItems: ComputedRef<UpdateCenterItem[]>;
allSelected: ComputedRef<boolean>; allSelected: ComputedRef<boolean>;
someSelected: ComputedRef<boolean>; someSelected: ComputedRef<boolean>;
selectableCount: ComputedRef<number>;
bind: () => void; bind: () => void;
unbind: () => void; unbind: () => void;
open: (storeFilter?: StoreFilter) => Promise<void>; open: (storeFilter?: StoreFilter) => Promise<void>;
@@ -35,6 +37,7 @@ export interface UpdateCenterStore {
ignoreItem: (packageName: string, newVersion: string) => Promise<void>; ignoreItem: (packageName: string, newVersion: string) => Promise<void>;
unignoreItem: (packageName: string, newVersion: string) => Promise<void>; unignoreItem: (packageName: string, newVersion: string) => Promise<void>;
toggleSelection: (taskKey: string) => void; toggleSelection: (taskKey: string) => void;
toggleForce: (taskKey: string) => void;
toggleSelectAll: () => void; toggleSelectAll: () => void;
getSelectedItems: () => UpdateCenterItem[]; getSelectedItems: () => UpdateCenterItem[];
closeNow: () => void; closeNow: () => void;
@@ -60,21 +63,32 @@ export const createUpdateCenterStore = (): UpdateCenterStore => {
const showMigrationConfirm = ref(false); const showMigrationConfirm = ref(false);
const searchQuery = ref(""); const searchQuery = ref("");
const selectedTaskKeys = ref(new Set<string>()); const selectedTaskKeys = ref(new Set<string>());
// 强制安装集合:仅当前会话内有效(刷新/重开更新中心即重置),不持久化。
// 仅对被系统锁定(held)的项有意义:开启后该 held 项可被单独选中并升级。
const forcedTaskKeys = ref(new Set<string>());
const snapshot = ref<UpdateCenterSnapshot>(EMPTY_SNAPSHOT); const snapshot = ref<UpdateCenterSnapshot>(EMPTY_SNAPSHOT);
let lastStoreFilter: StoreFilter = "both"; let lastStoreFilter: StoreFilter = "both";
// 判断某 item 是否可被选中:未被忽略,且(未被锁定 或 已开启强制安装)
const isSelectable = (item: UpdateCenterItem): boolean => {
if (item.ignored === true) return false;
if (item.held === true && !forcedTaskKeys.value.has(item.taskKey)) {
return false;
}
return true;
};
const resetSessionState = (): void => { const resetSessionState = (): void => {
showCloseConfirm.value = false; showCloseConfirm.value = false;
showMigrationConfirm.value = false; showMigrationConfirm.value = false;
searchQuery.value = ""; searchQuery.value = "";
selectedTaskKeys.value = new Set(); selectedTaskKeys.value = new Set();
forcedTaskKeys.value = new Set();
}; };
const applySnapshot = (nextSnapshot: UpdateCenterSnapshot): void => { const applySnapshot = (nextSnapshot: UpdateCenterSnapshot): void => {
const selectableTaskKeys = new Set( const selectableTaskKeys = new Set(
nextSnapshot.items nextSnapshot.items.filter(isSelectable).map((item) => item.taskKey),
.filter((item) => item.ignored !== true)
.map((item) => item.taskKey),
); );
selectedTaskKeys.value = new Set( selectedTaskKeys.value = new Set(
[...selectedTaskKeys.value].filter((taskKey) => [...selectedTaskKeys.value].filter((taskKey) =>
@@ -85,12 +99,20 @@ export const createUpdateCenterStore = (): UpdateCenterStore => {
}; };
const selectableItems = computed(() => const selectableItems = computed(() =>
snapshot.value.items.filter((item) => item.ignored !== true), snapshot.value.items.filter(isSelectable),
); );
const filteredItems = computed(() => { const filteredItems = computed(() => {
const query = searchQuery.value.trim(); const query = searchQuery.value.trim();
return snapshot.value.items.filter((item) => matchesSearch(item, query)); const matched = snapshot.value.items.filter((item) =>
matchesSearch(item, query),
);
// 已忽略项沉底:可选(含已强制的锁定项)在前、不可选(已忽略 / 未强制的锁定项)在后,
// 各自保持原有顺序
return [
...matched.filter(isSelectable),
...matched.filter((item) => !isSelectable(item)),
];
}); });
const allSelected = computed(() => { const allSelected = computed(() => {
@@ -109,6 +131,9 @@ export const createUpdateCenterStore = (): UpdateCenterStore => {
); );
}); });
// 可选项数量:被忽略 / 被锁定未强制的项不计入。用于全选按钮可用性判断。
const selectableCount = computed(() => selectableItems.value.length);
const handleState = (nextSnapshot: UpdateCenterSnapshot): void => { const handleState = (nextSnapshot: UpdateCenterSnapshot): void => {
applySnapshot(nextSnapshot); applySnapshot(nextSnapshot);
}; };
@@ -133,30 +158,27 @@ export const createUpdateCenterStore = (): UpdateCenterStore => {
isBound = false; isBound = false;
}; };
const open = async (storeFilter: StoreFilter = "both"): Promise<void> => { // 刷新软件源(不加载列表):网络慢/卡死由主进程超时保护,不会永久挂起
lastStoreFilter = storeFilter; const runSystemUpdate = async (storeFilter: StoreFilter): Promise<void> => {
resetSessionState();
isOpen.value = true;
loading.value = true;
try { try {
const nextSnapshot = await window.updateCenter.open(storeFilter); await window.ipcRenderer.invoke(
applySnapshot(nextSnapshot); "update-center-run-system-update",
} finally { storeFilter,
loading.value = false; );
} catch (error) {
console.error("[UpdateCenter] system update failed", error);
throw error;
} }
}; };
// 主动刷新:先刷新源再加载列表(用户点击刷新按钮时使用,期望即时结果)
const refresh = async ( const refresh = async (
storeFilter: StoreFilter = lastStoreFilter, storeFilter: StoreFilter = lastStoreFilter,
): Promise<void> => { ): Promise<void> => {
lastStoreFilter = storeFilter; lastStoreFilter = storeFilter;
loading.value = true; loading.value = true;
try { try {
// 先运行系统更新(aptss update / apm update),确保本地包信息最新 await runSystemUpdate(storeFilter);
await window.ipcRenderer.invoke(
"update-center-run-system-update",
storeFilter,
);
const nextSnapshot = await window.updateCenter.refresh(storeFilter); const nextSnapshot = await window.updateCenter.refresh(storeFilter);
applySnapshot(nextSnapshot); applySnapshot(nextSnapshot);
} finally { } finally {
@@ -164,6 +186,60 @@ export const createUpdateCenterStore = (): UpdateCenterStore => {
} }
}; };
// 打开更新中心:先用缓存秒开列表(不卡 UI),再后台刷新源并重新加载。
// 后台刷新带指数退避重试;窗口关闭即停,避免无效重试。
// 与 electron/main/index.ts 的预刷新重试为【对称设计】,非代码遗漏:
// 前端负责“打开更新中心兜底”,主进程负责“启动预热”,
// 两者进程/守卫/调用目标不同,故各自保留一份,勿抽共享。
const MAX_BACKGROUND_RETRIES = 3;
const BACKGROUND_BACKOFF_MS = [2000, 4000, 8000];
// 按重试次数取退避毫秒(越界时回退到最大间隔)
const getBackoffDelay = (attempt: number): number =>
BACKGROUND_BACKOFF_MS[attempt - 1] ??
BACKGROUND_BACKOFF_MS[BACKGROUND_BACKOFF_MS.length - 1];
const backgroundRefresh = async (
storeFilter: StoreFilter,
attempt: number,
): Promise<void> => {
if (!isOpen.value) return; // 窗口已关闭,停止重试
try {
await runSystemUpdate(storeFilter);
const nextSnapshot = await window.updateCenter.refresh(storeFilter);
if (!isOpen.value) return;
applySnapshot(nextSnapshot);
} catch (error) {
if (attempt >= MAX_BACKGROUND_RETRIES) {
console.warn(
`[UpdateCenter] background refresh failed after ${MAX_BACKGROUND_RETRIES} attempts`,
error,
);
return;
}
const delay = getBackoffDelay(attempt);
window.setTimeout(() => {
void backgroundRefresh(storeFilter, attempt + 1);
}, delay);
}
};
const open = async (storeFilter: StoreFilter = "both"): Promise<void> => {
lastStoreFilter = storeFilter;
resetSessionState();
isOpen.value = true;
loading.value = true;
try {
// 1. 先加载缓存,立即显示列表(秒开,不阻塞于网络刷新)
const cachedSnapshot = await window.updateCenter.open(storeFilter);
applySnapshot(cachedSnapshot);
} finally {
loading.value = false;
}
// 2. 后台异步刷新源并在完成后更新列表(失败自动重试)
void backgroundRefresh(storeFilter, 1);
};
const ignoreItem = async ( const ignoreItem = async (
packageName: string, packageName: string,
newVersion: string, newVersion: string,
@@ -182,7 +258,7 @@ export const createUpdateCenterStore = (): UpdateCenterStore => {
const item = snapshot.value.items.find( const item = snapshot.value.items.find(
(entry) => entry.taskKey === taskKey, (entry) => entry.taskKey === taskKey,
); );
if (!item || item.ignored === true) { if (!item || !isSelectable(item)) {
return; return;
} }
@@ -196,6 +272,32 @@ export const createUpdateCenterStore = (): UpdateCenterStore => {
selectedTaskKeys.value = nextSelection; selectedTaskKeys.value = nextSelection;
}; };
// 切换「强制安装」开关(仅对被系统锁定的包有意义)。
// 关闭强制时,若该包已被选中则同步取消其选中态。
const toggleForce = (taskKey: string): void => {
const item = snapshot.value.items.find(
(entry) => entry.taskKey === taskKey,
);
if (!item || item.held !== true) {
return;
}
const nextForced = new Set(forcedTaskKeys.value);
if (nextForced.has(taskKey)) {
nextForced.delete(taskKey);
// 取消强制后该包不可再被选中,移出选中集
if (selectedTaskKeys.value.has(taskKey)) {
const nextSelection = new Set(selectedTaskKeys.value);
nextSelection.delete(taskKey);
selectedTaskKeys.value = nextSelection;
}
} else {
nextForced.add(taskKey);
}
forcedTaskKeys.value = nextForced;
};
const toggleSelectAll = (): void => { const toggleSelectAll = (): void => {
const selectable = selectableItems.value; const selectable = selectableItems.value;
if (allSelected.value) { if (allSelected.value) {
@@ -208,7 +310,7 @@ export const createUpdateCenterStore = (): UpdateCenterStore => {
const getSelectedItems = (): UpdateCenterItem[] => { const getSelectedItems = (): UpdateCenterItem[] => {
return snapshot.value.items.filter( return snapshot.value.items.filter(
(item) => (item) =>
selectedTaskKeys.value.has(item.taskKey) && item.ignored !== true, selectedTaskKeys.value.has(item.taskKey) && isSelectable(item),
); );
}; };
@@ -270,6 +372,7 @@ export const createUpdateCenterStore = (): UpdateCenterStore => {
startTasks.push({ startTasks.push({
taskKey: item.taskKey, taskKey: item.taskKey,
id: downloadId, id: downloadId,
forceHeld: item.held === true,
}); });
} }
}); });
@@ -293,10 +396,12 @@ export const createUpdateCenterStore = (): UpdateCenterStore => {
showMigrationConfirm, showMigrationConfirm,
searchQuery, searchQuery,
selectedTaskKeys, selectedTaskKeys,
forcedTaskKeys,
snapshot, snapshot,
filteredItems, filteredItems,
allSelected, allSelected,
someSelected, someSelected,
selectableCount,
bind, bind,
unbind, unbind,
open, open,
@@ -304,6 +409,7 @@ export const createUpdateCenterStore = (): UpdateCenterStore => {
ignoreItem, ignoreItem,
unignoreItem, unignoreItem,
toggleSelection, toggleSelection,
toggleForce,
toggleSelectAll, toggleSelectAll,
getSelectedItems, getSelectedItems,
closeNow, closeNow,
+4
View File
@@ -22,6 +22,9 @@ declare global {
}; };
windowControls: WindowControlBridge; windowControls: WindowControlBridge;
updateCenter: UpdateCenterBridge; updateCenter: UpdateCenterBridge;
electronUtils: {
getPathForFile: (file: File) => string;
};
} }
} }
@@ -36,6 +39,7 @@ interface WindowControlBridge {
minimize: () => void; minimize: () => void;
toggleMaximize: () => void; toggleMaximize: () => void;
close: () => void; close: () => void;
state: () => Promise<| 'normal' | 'maximized' | 'minimized'>
} }
// IPC channel type definitions // IPC channel type definitions
+5 -5
View File
@@ -16,11 +16,11 @@ load_transhell
if [[ "$IS_APM_ENV" = "" ]] ;then if [[ "$IS_APM_ENV" = "" ]] ;then
case $(arch) in case $(arch) in
x86_64 | i686 | i386) x86_64 | i686 | i386)
STORE_URL="store" STORE_URL="amd64-store"
STORE_LIST_URL="" STORE_LIST_URL=""
;; ;;
aarch64) aarch64)
STORE_URL="aarch64-store" STORE_URL="arm64-store"
STORE_LIST_URL="-aarch64" STORE_LIST_URL="-aarch64"
;; ;;
loongarch64) loongarch64)
@@ -62,13 +62,13 @@ is_empty_dir(){
} }
function update_list(){ function update_list(){
curl --progress-bar -o /opt/durapps/spark-store/bin/apt-fast-conf/sources.list.d/aptss.list "${SPARK_DOWNLOAD_SERVER_URL}/${UPSTREAM_CATOGARY}${STORE_LIST_URL}.list" curl -L --progress-bar -o /opt/durapps/spark-store/bin/apt-fast-conf/sources.list.d/aptss.list "${SPARK_DOWNLOAD_SERVER_URL}/${UPSTREAM_CATOGARY}${STORE_LIST_URL}.list"
log.info "${UPSTREAM_CATOGARY}${STORE_LIST_URL}.list update done" log.info "${UPSTREAM_CATOGARY}${STORE_LIST_URL}.list update done"
} }
function update_conf(){ function update_conf(){
mkdir -p /tmp/aptss-conf/ mkdir -p /tmp/aptss-conf/
curl --progress-bar -o /tmp/aptss-conf/apt-fast.conf "${SPARK_DOWNLOAD_SERVER_URL}/apt-fast.conf" curl -L --progress-bar -o /tmp/aptss-conf/apt-fast.conf "${SPARK_DOWNLOAD_SERVER_URL}/apt-fast.conf"
log.info "apt-fast.conf update done" log.info "apt-fast.conf update done"
chmod -R 755 /tmp/aptss-conf chmod -R 755 /tmp/aptss-conf
} }
@@ -96,7 +96,7 @@ update_conf
fi fi
if [ ! -e "/var/lib/aptss/lists/${SPARK_DOWNLOAD_SERVER_URL_NO_PROTOCOL}_${STORE_URL}_Packages" ] && [ ! -e "/var/lib/aptss/lists/d.store.deepinos.org.cn_${STORE_URL}_Packages" ] && [ ! -e "/var/lib/aptss/lists/mirrors.sdu.edu.cn_spark-store-repository_${STORE_URL}_Packages" ];then if [ ! -e "/var/lib/aptss/lists/${SPARK_DOWNLOAD_SERVER_URL_NO_PROTOCOL}_${STORE_URL}_Packages" ] && [ ! -e "/var/lib/aptss/lists/${SPARK_DOWNLOAD_SERVER_URL_NO_PROTOCOL}_aarch64-store_Packages" ] && [ ! -e "/var/lib/aptss/lists/${SPARK_DOWNLOAD_SERVER_URL_NO_PROTOCOL}_store_Packages" ];then
mkdir -p /tmp/aptss-conf/ mkdir -p /tmp/aptss-conf/
echo -e "\e[1;32m${TRANSHELL_CONTENT_GETTING_SERVER_CONFIG_AND_MIRROR_LIST}\e[0m" echo -e "\e[1;32m${TRANSHELL_CONTENT_GETTING_SERVER_CONFIG_AND_MIRROR_LIST}\e[0m"
+10
View File
@@ -29,6 +29,16 @@ if grep -q "ID=aosc" /etc/os-release; then
ARGS="$ARGS --no-spark" ARGS="$ARGS --no-spark"
fi fi
# 检查龙GPU,添加 --disable-gpu
ARCH=$(uname -m)
if [ "$ARCH" = "loongarch64" ] || [ "$ARCH" = "loong64" ]; then
is_loonggpu=$(lspci -s $(basename $(readlink $(grep -l connected /sys/class/drm/card*/*/status 2>/dev/null | head -1 | grep -o 'card[0-9]*' | xargs -I{} echo /sys/class/drm/{}/device))) | grep -qi loongson && echo "Found" || echo "NotFound")
if [ "$is_loonggpu" = "Found" ]; then
echo "检测到龙GPU"
ARGS="$ARGS --disable-gpu"
fi
fi
# 注意:已移除原先针对 arm64 + wayland 添加 --disable-gpu 的逻辑, # 注意:已移除原先针对 arm64 + wayland 添加 --disable-gpu 的逻辑,
# 现在 arm64 设备无论是否使用 wayland 均不再添加此参数。 # 现在 arm64 设备无论是否使用 wayland 均不再添加此参数。
+8 -5
View File
@@ -62,11 +62,11 @@ export DEBIAN_FRONTEND=noninteractive
if [[ "$IS_APM_ENV" = "" ]] ;then if [[ "$IS_APM_ENV" = "" ]] ;then
case $(arch) in case $(arch) in
x86_64 | i686 | i386) x86_64 | i686 | i386)
STORE_URL="store" STORE_URL="amd64-store"
STORE_LIST_URL="" STORE_LIST_URL=""
;; ;;
aarch64) aarch64)
STORE_URL="aarch64-store" STORE_URL="arm64-store"
STORE_LIST_URL="-aarch64" STORE_LIST_URL="-aarch64"
;; ;;
loongarch64) loongarch64)
@@ -195,10 +195,13 @@ function hash_check() {
# 检查可能的仓库位置 # 检查可能的仓库位置
if [ -e "/var/lib/aptss/lists/${SPARK_DOWNLOAD_SERVER_URL_NO_PROTOCOL}_${STORE_URL}_Packages" ]; then if [ -e "/var/lib/aptss/lists/${SPARK_DOWNLOAD_SERVER_URL_NO_PROTOCOL}_${STORE_URL}_Packages" ]; then
PACKAGES_DATA_PATH="/var/lib/aptss/lists/${SPARK_DOWNLOAD_SERVER_URL_NO_PROTOCOL}_${STORE_URL}_Packages" PACKAGES_DATA_PATH="/var/lib/aptss/lists/${SPARK_DOWNLOAD_SERVER_URL_NO_PROTOCOL}_${STORE_URL}_Packages"
elif [ -e "/var/lib/aptss/lists/d.store.deepinos.org.cn_${STORE_URL}_Packages" ]; then elif [ -e "/var/lib/aptss/lists/${SPARK_DOWNLOAD_SERVER_URL_NO_PROTOCOL}_store_Packages" ]; then
PACKAGES_DATA_PATH="/var/lib/aptss/lists/d.store.deepinos.org.cn_${STORE_URL}_Packages" PACKAGES_DATA_PATH="/var/lib/aptss/lists/${SPARK_DOWNLOAD_SERVER_URL_NO_PROTOCOL}_store_Packages"
elif [ -e "/var/lib/aptss/lists/${SPARK_DOWNLOAD_SERVER_URL_NO_PROTOCOL}_aarch64-store_Packages" ]; then
PACKAGES_DATA_PATH="/var/lib/aptss/lists/${SPARK_DOWNLOAD_SERVER_URL_NO_PROTOCOL}_aarch64-store_Packages"
else else
PACKAGES_DATA_PATH="/var/lib/aptss/lists/mirrors.sdu.edu.cn_spark-store-repository_${STORE_URL}_Packages" echo "ERROR:NO_PACKAGE_FILE_FOUND!"
exit 1
fi fi
echo "正在运行包验证..." echo "正在运行包验证..."
+198
View File
@@ -0,0 +1,198 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>更新中心 · hold/强制安装 设计草图</title>
<style>
:root{
--brand:#0071e3; --brand-dark:#0066cc; --brand-soft:#409cff;
--amber:#f59e0b; --amber-soft:#fbbf24;
--bg:#f5f7fb; --card:#fff; --line:#e2e8f0; --txt:#0f172a; --sub:#64748b;
--danger:#ef4444;
}
*{box-sizing:border-box;font-family:"Inter","system-ui","Microsoft YaHei",sans-serif;}
body{margin:0;background:var(--bg);color:var(--txt);padding:32px;}
h1{font-size:20px;margin:0 0 4px;}
.hint{color:var(--sub);font-size:13px;margin-bottom:24px;line-height:1.6;}
.modal{width:100%;max-width:960px;margin:0 auto;background:var(--card);border:1px solid var(--line);
border-radius:24px;overflow:hidden;box-shadow:0 20px 50px rgba(15,23,42,.12);}
.toolbar{display:flex;align-items:center;gap:12px;padding:18px 24px;border-bottom:1px solid var(--line);}
.toolbar .title{font-weight:700;font-size:16px;}
.toolbar .spacer{flex:1;}
.btn{border:1px solid var(--line);background:#fff;border-radius:12px;padding:8px 14px;font-size:13px;cursor:pointer;}
.btn-primary{background:var(--brand);color:#fff;border-color:var(--brand);}
.btn:disabled{opacity:.5;cursor:not-allowed;}
.warn{margin:16px 24px;background:#fffbeb;border:1px solid #fde68a;color:#92400e;
border-radius:16px;padding:12px 16px;font-size:13px;line-height:1.6;}
.list{padding:8px 16px 20px;}
.item{position:relative;display:flex;align-items:center;gap:12px;border:1px solid var(--line);
border-radius:16px;padding:12px 16px;margin-top:10px;}
.item.held{background:#fffdf5;border-color:#fde68a;}
.item.sel{background:rgba(0,113,227,.04);}
.chk{width:18px;height:18px;accent-color:var(--brand);}
.icon{width:40px;height:40px;border-radius:10px;background:#eef2f7;display:flex;align-items:center;justify-content:center;color:#94a3b8;font-size:18px;}
.main{flex:1;min-width:0;}
.name-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;}
.name{font-weight:600;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.tag{font-size:10px;font-weight:600;border-radius:999px;padding:2px 8px;}
.tag-src{background:#eef2f7;color:#475569;}
.tag-held{background:#fef3c7;color:#b45309;}
.tag-ign{background:#e2e8f0;color:#64748b;}
.ver{font-size:12px;color:var(--sub);margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.right{text-align:right;font-size:12px;color:#475569;white-space:nowrap;}
.right .t{color:var(--brand);font-weight:600;font-size:11px;}
.right .s{color:#94a3b8;font-size:10px;}
.actions{display:flex;align-items:center;gap:6px;}
.iconbtn{width:32px;height:32px;border-radius:10px;border:1px solid var(--line);background:#fff;
display:flex;align-items:center;justify-content:center;cursor:pointer;color:#475569;font-size:13px;}
.iconbtn.amber{color:var(--amber);}
.iconbtn.amber:hover{background:#fffbeb;}
/* 强制安装:开关样式 */
.force{display:flex;align-items:center;gap:6px;}
.switch{position:relative;width:34px;height:18px;border-radius:999px;background:#cbd5e1;transition:.2s;flex:none;}
.switch.on{background:var(--brand);}
.switch::after{content:"";position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:#fff;transition:.2s;}
.switch.on::after{left:18px;}
.force-label{font-size:11px;color:var(--sub);}
/* 底部说明条 */
.legend{margin:0 24px 20px;font-size:12px;color:var(--sub);line-height:1.8;
border-top:1px dashed var(--line);padding-top:14px;}
.legend b{color:var(--txt);}
/* 布局说明块 */
.notes{max-width:960px;margin:28px auto 0;background:#fff;border:1px solid var(--line);
border-radius:16px;padding:18px 22px;font-size:13px;line-height:1.8;color:#334155;}
.notes h2{font-size:15px;margin:0 0 10px;}
.notes code{background:#f1f5f9;padding:1px 6px;border-radius:6px;font-size:12px;color:#0f172a;}
.notes li{margin-bottom:6px;}
</style>
</head>
<body>
<h1>更新中心 — hold 标签 & 强制安装(设计草图)</h1>
<p class="hint">
说明:与现有「忽略」机制一致 —— 被系统 <code>apt-mark hold</code> 的包在更新中心默认<b>不可选中</b>(复选框禁用),
并在软件上显示「已锁定 / hold」标签;同时该软件行提供<b>「强制安装」开关</b>,打开后该包可单独被选中并升级
(后端对 spark 源追加 <code>--allow-change-held-packages</code>)。其他功能(忽略、批量升级、进度)不变。
</p>
<div class="modal">
<div class="toolbar">
<span class="title">更新中心</span>
<span class="spacer"></span>
<button class="btn">全选</button>
<button class="btn">刷新</button>
<button class="btn" disabled>开始更新 (2)</button>
<button class="btn">关闭</button>
</div>
<div class="warn">
⚠ 检测到 1 个软件被系统锁定(hold),默认不会升级。如需强制升级,请在对应软件上打开「强制安装」。
</div>
<div class="list">
<!-- 普通可升级项 -->
<div class="item sel">
<input type="checkbox" class="chk" checked />
<div class="icon">📦</div>
<div class="main">
<div class="name-row">
<span class="name">deepin-terminal</span>
<span class="tag tag-src">传统deb</span>
</div>
<div class="ver">6.0.1 → 6.0.2</div>
</div>
<div class="right"><div class="t">今天</div><div class="s">48 MB</div></div>
<div class="actions">
<div class="iconbtn amber" title="忽略更新">👁̶</div>
</div>
</div>
<!-- 被 hold 的软件:默认禁用复选框 + 显示 hold 标签 + 提供强制安装开关 -->
<div class="item held">
<input type="checkbox" class="chk" disabled title="已被系统锁定,需开启强制安装" />
<div class="icon">📦</div>
<div class="main">
<div class="name-row">
<span class="name">code</span>
<span class="tag tag-src">传统deb</span>
<span class="tag tag-held">已锁定</span>
</div>
<div class="ver">1.133.0 → 1.133.1</div>
</div>
<div class="right"><div class="t">3天前</div><div class="s">95 MB</div></div>
<div class="actions">
<!-- 强制安装开关 -->
<div class="force" title="强制安装被锁定的软件">
<div class="switch"></div>
<span class="force-label">强制</span>
</div>
<div class="iconbtn amber" title="忽略更新">👁̶</div>
</div>
</div>
<!-- 已开启强制安装的 hold 软件:复选框可用,标签变为「将强制」 -->
<div class="item held sel">
<input type="checkbox" class="chk" checked />
<div class="icon">📦</div>
<div class="main">
<div class="name-row">
<span class="name">code</span>
<span class="tag tag-src">传统deb</span>
<span class="tag tag-held">将强制</span>
</div>
<div class="ver">1.133.0 → 1.133.1</div>
</div>
<div class="right"><div class="t">3天前</div><div class="s">95 MB</div></div>
<div class="actions">
<div class="force" title="强制安装被锁定的软件">
<div class="switch on"></div>
<span class="force-label">强制</span>
</div>
<div class="iconbtn amber" title="忽略更新">👁̶</div>
</div>
</div>
<!-- 已忽略项(沉底,仅作对照) -->
<div class="item" style="opacity:.7;">
<input type="checkbox" class="chk" disabled />
<div class="icon">📦</div>
<div class="main">
<div class="name-row">
<span class="name">old-app</span>
<span class="tag tag-src">APM</span>
<span class="tag tag-ign">已忽略</span>
</div>
<div class="ver">2.1 → 2.2</div>
</div>
<div class="right"><div class="t">30天前</div><div class="s"></div></div>
<div class="actions">
<div class="iconbtn" title="取消忽略"></div>
</div>
</div>
</div>
<div class="legend">
<b>图例:</b>
<b>普通项</b>:可正常勾选升级;
<b>已锁定(hold)项</b>:复选框禁用、显示「已锁定」黄色标签,与「已忽略」一样沉底、不可随「全选」选中;
③ 每行末的 <b>「强制」开关</b> 仅对被锁定的软件显示,打开后复选框启用、标签变「将强制」,可被单独勾选升级;
<b>忽略</b>按钮(👁̶)对所有可操作项保留,逻辑不变。
</div>
</div>
<div class="notes">
<h2>实现逻辑(动手前请确认此方案)</h2>
<ul>
<li><b>数据来源</b>:后端 <code>service.ts</code> 构建 <code>UpdateCenterItem</code> 时,调用 <code>apt-mark showhold</code>(或 <code>dpkg</code>)检测该包是否被 hold,新增字段 <code>held?: boolean</code>;前端类型同步加该字段。</li>
<li><b>默认不可选</b>:与 <code>ignored</code> 一致——被 hold 的项的复选框 <code>:disabled</code><code>toggleSelection</code>/<code>selectableItems</code>/<code>filteredItems</code>/<code>start()</code> 中把它当作不可选(<code>held === true</code><code>ignored === true</code> 处理)。</li>
<li><b>强制开关</b>:前端 store 维护一个 <code>forcedTaskKeys: Set&lt;string&gt;</code>;开关打开的项,即使 <code>held</code> 也允许被 <code>selected</code><code>start()</code> 时把 <code>forced</code> 标记带入 <code>QueueInstallPayload</code></li>
<li><b>后端执行</b><code>install-manager.ts</code><code>addInstallTask</code> 收到 <code>forceHeld: true</code><code>origin==="spark"</code> 时,给 <code>ssinstall</code> 追加 <code>--allow-change-held-packages</code>(仅 spark 源;apm 源不涉及 apt hold)。</li>
<li><b>状态文案修复(顺带)</b>:前次日志「下载完成」误把安装失败当成功;本次如需一并修,可在 <code>processInstall.ts</code> 区分下载/安装完成文案,并让 <code>install-complete</code> 的 success 真实反映 apt 退出码(ssinstall 放弃安装却返回 0 的问题需另行处理)。<em>此项是否做,等你指示。</em></li>
<li><b>「强制」状态不持久化</b>:与选中态一样,仅本次弹窗会话内有效,刷新/重开恢复默认(避免用户忘记关掉强制)。</li>
</ul>
</div>
</body>
</html>