mirror of
https://gitee.com/spark-store-project/spark-store
synced 2026-06-22 22:23:49 +08:00
Compare commits
120 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 274e086bb1 | |||
| 341d87cd8d | |||
| 337c7b8200 | |||
| 399b59dae8 | |||
| 23b09ca863 | |||
| 601d3f51f4 | |||
| cd3e087cdf | |||
| bd8d070f32 | |||
| 3d964c9473 | |||
| 3aa96f27c7 | |||
| 3847463b6e | |||
| 8e7c6bc67d | |||
| c3ea2ddf1b | |||
| 21e8a2ca3e | |||
| e7b90e5727 | |||
| 9e8758b5f2 | |||
| e84c1d86bf | |||
| e39525901e | |||
| f7eeddf6d9 | |||
| 24d55d0997 | |||
| 439af8c26f | |||
| 8e8617218a | |||
| 97f49201b7 | |||
| abeb511c06 | |||
| 8d80a02316 | |||
| 932e69fca7 | |||
| 87d0cdc036 | |||
| 1de42a261a | |||
| d03c8aab61 | |||
| ceb861a5b7 | |||
| ad831ce146 | |||
| a341071a3e | |||
| 0895eb5049 | |||
| fd17fc127d | |||
| 04b0ca061b | |||
| deff1c20c4 | |||
| a8a00d8165 | |||
| 341c740ced | |||
| 839f4017f8 | |||
| 34551fce7b | |||
| 753f91e837 | |||
| acffb6c5ee | |||
| ac1f46bd73 | |||
| bbd9cbccb7 | |||
| f280039874 | |||
| b839e0770c | |||
| 4b81869b6e | |||
| 4c2225290c | |||
| 78a04fb51f | |||
| 8da044495a | |||
| 3a4aa7807a | |||
| 3a8baf606c | |||
| 58789ecd1f | |||
| e116dcee63 | |||
| 75df598bc0 | |||
| e607e4991b | |||
| c2e8b9a1b4 | |||
| 62081fb0ad | |||
| 63dac217c2 | |||
| c24c88458c | |||
| 881aceb78c | |||
| 960bababc5 | |||
| 82d097330a | |||
| c877f0551e | |||
| f62665cd73 | |||
| e16acbd0a5 | |||
| 8a5f8d154f | |||
| 8c8b53fc29 | |||
| c50655c106 | |||
| 4b37aa4da4 | |||
| ce5de692f7 | |||
| 3d4af0c492 | |||
| c39b25e393 | |||
| 2086152aa5 | |||
| f8f112a782 | |||
| 6a9091b2ec | |||
| 994dbaf9b9 | |||
| 9c9f0b6076 | |||
| 42046caf2c | |||
| e72553d570 | |||
| 309b9bc003 | |||
| 0b784af3d7 | |||
| e1ec526cb9 | |||
| 120233cf56 | |||
| a2d4192592 | |||
| c907fbb5d4 | |||
| 68dd6a0a26 | |||
| 9eb141ee35 | |||
| f9aa31d257 | |||
| 1410a80df5 | |||
| fcdd982637 | |||
| bed2d43e0e | |||
| 44587e299a | |||
| 36f5d3831e | |||
| 51664619f5 | |||
| bd8b50677e | |||
| 78c9679f88 | |||
| c9c84e518b | |||
| f044c6c3df | |||
| 763af5c37e | |||
| ca7520cb2e | |||
| ba10f90dde | |||
| a280d44481 | |||
| 9244708b90 | |||
| c46bb03e3f | |||
| 71db2f2b71 | |||
| 67aa83fe26 | |||
| 60628ff1fa | |||
| 81cd00661c | |||
| 5ebbf8c223 | |||
| 68ab999eed | |||
| 9080d76575 | |||
| e2f59b3cdf | |||
| 6fcfa438d9 | |||
| fa2689c753 | |||
| 7bf2a5c55b | |||
| 62c1e51223 | |||
| a4a2ec4216 | |||
| a513c81606 | |||
| bcef173049 |
@@ -1,3 +1,4 @@
|
||||
VITE_APM_STORE_LOCAL_MODE=true
|
||||
VITE_APM_STORE_BASE_URL=/local_amd64-store
|
||||
VITE_APM_STORE_STATS_BASE_URL=/local_stats
|
||||
VITE_SPARK_BACKEND_BASE_URL=http://127.0.0.1:8000
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
VITE_APM_STORE_BASE_URL=https://erotica.spark-app.store
|
||||
VITE_APM_STORE_STATS_BASE_URL=https://feedback.spark-app.store
|
||||
VITE_SPARK_BACKEND_BASE_URL=https://account.spark-app.store
|
||||
|
||||
+13
@@ -14,6 +14,17 @@ dist-electron
|
||||
release
|
||||
*.local
|
||||
|
||||
# Nix build outputs
|
||||
/result
|
||||
/result-*
|
||||
|
||||
# Local secrets and databases
|
||||
.env
|
||||
.env.*.local
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
*.db
|
||||
|
||||
# Test coverage
|
||||
coverage
|
||||
.nyc_output
|
||||
@@ -39,3 +50,5 @@ yarn.lock
|
||||
.lock
|
||||
|
||||
test-results.json
|
||||
.worktrees/
|
||||
.superpowers/
|
||||
|
||||
+20
@@ -23,3 +23,23 @@
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
2. https://github.com/elysia-best/apm-app-store MulanPSL-2.0
|
||||
|
||||
Copyright (c) 2026-present The Spark Project Contributors
|
||||
|
||||
apm-store is licensed under Mulan PSL v2.
|
||||
|
||||
You can use this software according to the terms and conditions of the Mulan PSL v2.
|
||||
|
||||
You may obtain a copy of Mulan PSL v2 at:
|
||||
|
||||
http://license.coscl.org.cn/MulanPSL2
|
||||
|
||||
THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND,
|
||||
|
||||
EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,
|
||||
|
||||
MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
|
||||
|
||||
See the Mulan PSL v2 for more details.
|
||||
+1
-1
@@ -12,7 +12,7 @@
|
||||
|
||||
### 系统要求
|
||||
|
||||
- **Node.js:** >= 20.x
|
||||
- **Node.js:** >= 22.12.0
|
||||
- **npm:** >= 9.x 或 pnpm >= 8.x
|
||||
- **操作系统:** Linux(推荐 Ubuntu 22.04+)
|
||||
- **可选:** APM 包管理器(用于测试)
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
.DEFAULT_GOAL := build
|
||||
|
||||
clean:
|
||||
rm -rf release/
|
||||
|
||||
build:
|
||||
npm run build:deb
|
||||
|
||||
install:
|
||||
mkdir -p $(DESTDIR)/opt/spark-store/bin/
|
||||
mkdir -p $(DESTDIR)/opt/spark-store/extras/
|
||||
mkdir -p $(DESTDIR)/opt/durapps/spark-store/bin/
|
||||
mkdir -p $(DESTDIR)/usr/share/icons/
|
||||
mkdir -p $(DESTDIR)/usr/lib/
|
||||
mkdir -p $(DESTDIR)/usr/bin/
|
||||
cp -rv release/*/linux-unpacked/* $(DESTDIR)/opt/spark-store/bin/
|
||||
cp -rv release/*/linux-unpacked/extras/* $(DESTDIR)/opt/spark-store/extras/
|
||||
cp -rv tool/* $(DESTDIR)/opt/durapps/spark-store/bin/
|
||||
cp -rv pkg/usr/share/fish/ $(DESTDIR)/usr/share/
|
||||
cp -rv icons/hicolor/ $(DESTDIR)/usr/share/icons/
|
||||
cp -rv pkg/usr/share/icons/hicolor/ $(DESTDIR)/usr/share/icons/
|
||||
cp -rv pkg/usr/lib/systemd $(DESTDIR)/usr/lib/
|
||||
cp -rv pkg/usr/share/applications/ $(DESTDIR)/usr/share/
|
||||
cp -rv pkg/usr/share/polkit-1 $(DESTDIR)/usr/share/
|
||||
cp -rv pkg/usr/share/aptss $(DESTDIR)/usr/share/
|
||||
cp -rv 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
|
||||
@@ -18,7 +18,7 @@ Linux 应用的数量相对有限,Wine 软件的可获取性也颇为困难。
|
||||
|
||||
**当前支持的 Linux 发行版包括(但不限于):**
|
||||
|
||||
- **amd64 架构:** Debian 10+ / Ubuntu 22.04+ / Arch Linux / Fedora / deepin / UOS / 银河麒麟
|
||||
- **amd64 架构:** Debian 10+ / Ubuntu 22.04+ / Arch Linux / Fedora / deepin / UOS / 银河麒麟 / NixOS
|
||||
- **arm64 架构:** Debian 10+ / Ubuntu 22.04+ / Arch Linux / deepin / UOS / 银河麒麟
|
||||
- **loong64 架构:** deepin 23/25
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# 侧边栏入口配置 (Sidebar Config)
|
||||
|
||||
星火应用商店支持通过服务器上的 JSON 文件动态配置左侧侧边栏的入口项。
|
||||
|
||||
## 配置文件位置
|
||||
|
||||
将 `sidebar-config.json` 放置在服务器应用仓库的架构目录下:
|
||||
|
||||
```
|
||||
# Spark 仓库
|
||||
{baseUrl}/{arch}-store/sidebar-config.json
|
||||
|
||||
# APM 仓库
|
||||
{baseUrl}/{arch}-apm/sidebar-config.json
|
||||
```
|
||||
|
||||
例如:
|
||||
- `https://example.com/amd64-store/sidebar-config.json`
|
||||
- `https://example.com/arm64-store/sidebar-config.json`
|
||||
|
||||
## JSON 格式
|
||||
|
||||
每个入口项为一个对象,包含以下字段:
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `id` | string | ✅ | 唯一标识符,对应分类名或自定义 ID |
|
||||
| `name` | string | ✅ | 侧边栏显示的入口名称 |
|
||||
| `icon` | string | ❌ | FontAwesome 图标类名,如 `fas fa-gamepad` |
|
||||
| `type` | string | ❌ | 入口类型:`category`(分类筛选)、`search`(搜索关键词)、`link`(外部链接)。默认为 `category` |
|
||||
| `value` | string | ❌ | 与 `type` 配合使用的值。`category` 类型为分类名,`search` 类型为搜索关键词。默认为 `id` 的值 |
|
||||
|
||||
## 示例配置
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "games",
|
||||
"name": "游戏专区",
|
||||
"icon": "fas fa-gamepad",
|
||||
"type": "category",
|
||||
"value": "games"
|
||||
},
|
||||
{
|
||||
"id": "devtools",
|
||||
"name": "开发工具",
|
||||
"icon": "fas fa-code",
|
||||
"type": "category",
|
||||
"value": "development"
|
||||
},
|
||||
{
|
||||
"id": "office",
|
||||
"name": "办公学习",
|
||||
"icon": "fas fa-book",
|
||||
"type": "category",
|
||||
"value": "office"
|
||||
},
|
||||
{
|
||||
"id": "ai-search",
|
||||
"name": "AI 应用",
|
||||
"icon": "fas fa-robot",
|
||||
"type": "search",
|
||||
"value": "AI"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## 入口类型说明
|
||||
|
||||
### `category` 类型
|
||||
点击后在"全部应用"页面按指定分类筛选应用。`value` 字段对应 `categories.json` 中的分类键名。
|
||||
|
||||
### `search` 类型
|
||||
点击后自动使用 `value` 字段的值进行搜索。适用于快速入口,如"AI 应用"、"微信"等热门关键词。
|
||||
|
||||
### `link` 类型(预留)
|
||||
用于跳转到外部链接或内部页面。后续版本支持。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 如果两个仓库(Spark 和 APM)都存在 `sidebar-config.json`,相同的 `id` 会自动去重合并
|
||||
- 配置文件不存在时,侧边栏不会显示额外的入口项,不影响正常使用
|
||||
- 入口项显示在"首页推荐"和"全部应用"之间,以分隔线区分
|
||||
- 每个入口项会显示对应分类或搜索下的应用数量
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
spark-store (5.2.0~alpha1) UNRELEASED; urgency=medium
|
||||
|
||||
* Initial release. (Closes: #nnnn) <nnnn is the bug number of your ITP>
|
||||
|
||||
-- shenmo <shenmo@spark-app.store> Tue, 16 Jun 2026 21:45:35 +0800
|
||||
Vendored
+40
@@ -0,0 +1,40 @@
|
||||
Source: spark-store
|
||||
Section: utils
|
||||
Priority: optional
|
||||
Maintainer: shenmo <shenmo@spark-app.store>
|
||||
Rules-Requires-Root: no
|
||||
Build-Depends:
|
||||
debhelper-compat (= 13),
|
||||
make,
|
||||
npm,
|
||||
Standards-Version: 4.7.2
|
||||
Homepage: https://www.spark-app.store/
|
||||
|
||||
Package: spark-store
|
||||
Architecture: any
|
||||
Provides: spark-store-console-in-container
|
||||
Depends:
|
||||
${shlibs:Depends},
|
||||
${misc:Depends},
|
||||
libgtk-3-0,
|
||||
libnotify4,
|
||||
libnss3,
|
||||
libxss1,
|
||||
libxtst6,
|
||||
xdg-utils,
|
||||
libatspi2.0-0,
|
||||
libuuid1,
|
||||
libsecret-1-0,
|
||||
xdg-utils,
|
||||
shared-mime-info,
|
||||
aria2,
|
||||
gnupg,
|
||||
zenity,
|
||||
policykit-1 | pkexec,
|
||||
libnotify-bin,
|
||||
desktop-file-utils,
|
||||
lsb-release,
|
||||
systemd,
|
||||
curl
|
||||
Description: Spark Store
|
||||
A community powered app store, powered by APM.
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
#!/bin/bash
|
||||
|
||||
case "$1" in
|
||||
configure)
|
||||
|
||||
case `arch` in
|
||||
x86_64)
|
||||
echo "Enabling i386 arch..."
|
||||
dpkg --add-architecture i386
|
||||
;;
|
||||
|
||||
aarch64)
|
||||
echo "Will not enable armhf since 4271"
|
||||
;;
|
||||
loongarch64)
|
||||
echo "Nope we DO NOT WANT ABI1 now"
|
||||
dpkg --remove-architecture loongarch64
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "Unknown architecture, skip enable 32-bit arch"
|
||||
;;
|
||||
esac
|
||||
|
||||
mkdir -p /var/lib/aptss/lists
|
||||
|
||||
# Remove the sources.list file
|
||||
rm -f /etc/apt/sources.list.d/sparkstore.list
|
||||
rm -f /opt/durapps/spark-store/bin/apt-fast-conf/sources.list.d/sparkstore.list
|
||||
|
||||
# Check if /usr/local/bin existed
|
||||
mkdir -p /usr/local/bin
|
||||
## I hate /usr/local/bin. We will abandon them later
|
||||
# Create symbol links for binary files
|
||||
ln -s -f /opt/durapps/spark-store/bin/spark-store /usr/local/bin/spark-store
|
||||
ln -s -f /opt/durapps/spark-store/bin/ssinstall /usr/local/bin/ssinstall
|
||||
ln -s -f /opt/durapps/spark-store/bin/ssaudit /usr/local/bin/ssaudit
|
||||
ln -s -f /opt/durapps/spark-store/bin/ssinstall /usr/bin/ssinstall
|
||||
ln -s -f /opt/durapps/spark-store/bin/ssaudit /usr/bin/ssaudit
|
||||
ln -s -f /opt/durapps/spark-store/bin/spark-dstore-patch /usr/local/bin/spark-dstore-patch
|
||||
ln -s -f /opt/durapps/spark-store/bin/spark-dstore-patch /usr/bin/spark-dstore-patch
|
||||
ln -s -f /opt/durapps/spark-store/bin/aptss /usr/local/bin/ss-apt-fast
|
||||
|
||||
ln -s -f /opt/durapps/spark-store/bin/aptss /usr/bin/aptss
|
||||
|
||||
|
||||
|
||||
# Install key
|
||||
mkdir -p /tmp/spark-store-install/
|
||||
cp -f /opt/durapps/spark-store/bin/spark-store.asc /tmp/spark-store-install/spark-store.asc
|
||||
gpg --dearmor /tmp/spark-store-install/spark-store.asc
|
||||
cp -f /tmp/spark-store-install/spark-store.asc.gpg /etc/apt/trusted.gpg.d/spark-store.gpg
|
||||
|
||||
|
||||
|
||||
# Start upgrade detect service
|
||||
systemctl daemon-reload
|
||||
systemctl enable spark-update-notifier
|
||||
systemctl start spark-update-notifier
|
||||
|
||||
|
||||
# Update certain caches
|
||||
cp -fv /opt/spark-store/extras/store.spark-app.spark-store.policy /usr/share/polkit-1/actions/store.spark-app.spark-store.policy
|
||||
xdg-mime default spark-store.desktop x-scheme-handler/spk
|
||||
update-mime-database /usr/share/mime || true
|
||||
|
||||
# Send email for statistics
|
||||
#/tmp/spark-store-install/feedback.sh
|
||||
|
||||
# Remove temp dir
|
||||
rm -rf /tmp/spark-store-install
|
||||
;;
|
||||
|
||||
triggered)
|
||||
spark-dstore-patch
|
||||
|
||||
;;
|
||||
esac
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
#!/bin/bash
|
||||
rm -fv /usr/share/polkit-1/actions/store.spark-app.spark-store.policy
|
||||
|
||||
# Update certain caches
|
||||
update-icon-caches /usr/share/icons/hicolor || true
|
||||
update-desktop-database /usr/share/applications || true
|
||||
update-mime-database /usr/share/mime || true
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
#!/bin/bash
|
||||
#检测网络链接畅通
|
||||
function network-check()
|
||||
{
|
||||
#超时时间
|
||||
local timeout=15
|
||||
|
||||
#目标网站
|
||||
local target=www.baidu.com
|
||||
|
||||
#获取响应状态码
|
||||
local ret_code=`curl -I -s --connect-timeout ${timeout} ${target} -w %{http_code} | tail -n1`
|
||||
|
||||
if [ "x$ret_code" = "x200" ]; then
|
||||
echo "Network Checked successful ! Continue..."
|
||||
echo "网络通畅,继续安装"
|
||||
else
|
||||
#网络不畅通
|
||||
echo "Network failed ! Cancel the installation"
|
||||
echo "网络不畅,终止安装"
|
||||
exit -1
|
||||
fi
|
||||
|
||||
}
|
||||
|
||||
|
||||
#network-check
|
||||
echo "不再检测网络"
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
#!/bin/bash
|
||||
|
||||
function notify-send()
|
||||
{
|
||||
# Detect the user using such display
|
||||
local user=$(who | awk '{print $1}' | head -n 1)
|
||||
|
||||
# Detect the id of the user
|
||||
local uid=$(id -u $user)
|
||||
|
||||
sudo -u $user DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$uid/bus notify-send "$@"
|
||||
}
|
||||
|
||||
if [ "$1" = "remove" -o "$1" = "purge" ] ; then
|
||||
echo "$1"
|
||||
echo "卸载操作,进行配置清理"
|
||||
|
||||
# Remove residual symbol links
|
||||
unlink /usr/local/bin/spark-store
|
||||
unlink /usr/local/bin/ssinstall
|
||||
unlink /usr/local/bin/ssaudit
|
||||
unlink /usr/bin/ssinstall
|
||||
unlink /usr/bin/ssaudit
|
||||
unlink /usr/local/bin/spark-dstore-patch
|
||||
unlink /usr/bin/spark-dstore-patch
|
||||
unlink /usr/local/bin/ss-apt-fast
|
||||
unlink /usr/bin/aptss
|
||||
|
||||
rm -rf /etc/aptss/
|
||||
rm -rf /var/lib/aptss/
|
||||
|
||||
# Remove residual symbol links to stop upgrade detect
|
||||
rm -f /etc/xdg/autostart/spark-update-notifier.desktop
|
||||
# Remove config files
|
||||
for username in `ls /home`
|
||||
do
|
||||
echo /home/$username
|
||||
if [ -d /home/$username/.config/spark-union/spark-store ]
|
||||
then
|
||||
rm -rf /home/$username/.config/spark-union/spark-store
|
||||
fi
|
||||
done
|
||||
|
||||
|
||||
# Shutdown services
|
||||
systemctl stop spark-update-notifier
|
||||
# Stop update detect service
|
||||
systemctl disable spark-update-notifier
|
||||
|
||||
|
||||
|
||||
# Remove gpg key file
|
||||
rm -f /etc/apt/trusted.gpg.d/spark-store.gpg
|
||||
apt-key del '9D9A A859 F750 24B1 A1EC E16E 0E41 D354 A29A 440C' || true
|
||||
else
|
||||
|
||||
if [ ! -z "`pidof spark-store`" ] ; then
|
||||
echo "关闭已有 spark-store.."
|
||||
notify-send "正在升级星火商店" "请在升级结束后重启星火商店" -i spark-store
|
||||
killall spark-store
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/make -f
|
||||
|
||||
# See debhelper(7) (uncomment to enable).
|
||||
# Output every command that modifies files on the build system.
|
||||
#export DH_VERBOSE = 1
|
||||
|
||||
|
||||
# See FEATURE AREAS in dpkg-buildflags(1).
|
||||
#export DEB_BUILD_MAINT_OPTIONS = hardening=+all
|
||||
|
||||
# See ENVIRONMENT in dpkg-buildflags(1).
|
||||
# Package maintainers to append CFLAGS.
|
||||
#export DEB_CFLAGS_MAINT_APPEND = -Wall -pedantic
|
||||
# Package maintainers to append LDFLAGS.
|
||||
#export DEB_LDFLAGS_MAINT_APPEND = -Wl,--as-needed
|
||||
|
||||
|
||||
%:
|
||||
dh $@
|
||||
|
||||
override_dh_dwz:
|
||||
true
|
||||
|
||||
override_dh_strip:
|
||||
true
|
||||
|
||||
override_dh_shlibdeps:
|
||||
true
|
||||
|
||||
# dh_make generated override targets.
|
||||
# This is an example for Cmake (see <https://bugs.debian.org/641051>).
|
||||
#override_dh_auto_configure:
|
||||
# dh_auto_configure -- \
|
||||
# -DCMAKE_LIBRARY_PATH=$(DEB_HOST_MULTIARCH)
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
3.0 (quilt)
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
interest-noawait /opt/apps
|
||||
|
||||
@@ -0,0 +1,801 @@
|
||||
# Update Center Icon Fallback Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Change Electron update-center icons to load in the order `localIcon -> remoteIcon -> placeholder`, so a successful local icon never triggers remote/default loading, but failed local loads still fall through to the remote icon.
|
||||
|
||||
**Architecture:** Split the current single `icon` field into two explicit sources resolved in the main process: `localIcon` and `remoteIcon`. Keep URL/path resolution in `electron/main/backend/update-center/icons.ts`, pass both fields through the service snapshot, and let `UpdateCenterItem.vue` own the runtime fallback state when `img` emits `error`.
|
||||
|
||||
**Tech Stack:** Electron main process, Node.js `fs`/`path`, Vue 3 `<script setup>`, TypeScript strict mode, Vitest, Testing Library Vue.
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
- Modify: `electron/main/backend/update-center/types.ts` - replace the single update-center icon field with `localIcon` and `remoteIcon`.
|
||||
- Modify: `electron/main/backend/update-center/icons.ts` - keep local/remote resolution helpers and return both candidates via `resolveUpdateItemIcons()`.
|
||||
- Modify: `electron/main/backend/update-center/index.ts` - enrich loaded update items with the two icon fields instead of one final `icon`.
|
||||
- Modify: `electron/main/backend/update-center/service.ts` - expose `localIcon` and `remoteIcon` to renderer item/task snapshots.
|
||||
- Modify: `src/global/typedefinition.ts` - update renderer-facing update-center item/task types.
|
||||
- Modify: `src/components/update-center/UpdateCenterItem.vue` - render the current icon candidate and advance from local to remote to placeholder on load failures.
|
||||
- Modify: `src/__tests__/unit/update-center/icons.test.ts` - verify icon helper output is now `{ localIcon?, remoteIcon? }`.
|
||||
- Modify: `src/__tests__/unit/update-center/load-items.test.ts` - verify loaded items receive `remoteIcon` instead of the old `icon` field.
|
||||
- Modify: `src/__tests__/unit/update-center/registerUpdateCenter.test.ts` - verify service task snapshots preserve both icon fields.
|
||||
- Modify: `src/__tests__/unit/update-center/UpdateCenterItem.test.ts` - verify the renderer fallback order.
|
||||
|
||||
### Task 1: Split Backend Icon Resolution Into Local And Remote Sources
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `electron/main/backend/update-center/types.ts`
|
||||
- Modify: `electron/main/backend/update-center/icons.ts`
|
||||
- Test: `src/__tests__/unit/update-center/icons.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Replace the single-icon assertions in `src/__tests__/unit/update-center/icons.test.ts` with these four tests:
|
||||
|
||||
```ts
|
||||
it("returns both localIcon and remoteIcon when an aptss desktop icon resolves", async () => {
|
||||
const pkgname = "spark-weather";
|
||||
const applicationsDirectory = "/usr/share/applications";
|
||||
const desktopPath = `${applicationsDirectory}/weather-launcher.desktop`;
|
||||
const iconPath = `/usr/share/pixmaps/${pkgname}.png`;
|
||||
const { resolveUpdateItemIcons } = await loadIconsModule({
|
||||
directories: {
|
||||
[applicationsDirectory]: ["weather-launcher.desktop"],
|
||||
},
|
||||
files: {
|
||||
[desktopPath]: `[Desktop Entry]\nName=Spark Weather\nIcon=${iconPath}\n`,
|
||||
[iconPath]: "png",
|
||||
},
|
||||
packageFiles: {
|
||||
[pkgname]: [desktopPath],
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveUpdateItemIcons({
|
||||
pkgname,
|
||||
source: "aptss",
|
||||
currentVersion: "1.0.0",
|
||||
nextVersion: "2.0.0",
|
||||
category: "tools",
|
||||
arch: "amd64",
|
||||
}),
|
||||
).toEqual({
|
||||
localIcon: iconPath,
|
||||
remoteIcon:
|
||||
"https://erotica.spark-app.store/amd64-store/tools/spark-weather/icon.png",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns only remoteIcon when no local icon resolves", async () => {
|
||||
const { resolveUpdateItemIcons } = await loadIconsModule({});
|
||||
|
||||
expect(
|
||||
resolveUpdateItemIcons({
|
||||
pkgname: "spark-clock",
|
||||
source: "apm",
|
||||
currentVersion: "1.0.0",
|
||||
nextVersion: "2.0.0",
|
||||
category: "utility",
|
||||
arch: "amd64",
|
||||
}),
|
||||
).toEqual({
|
||||
remoteIcon:
|
||||
"https://erotica.spark-app.store/amd64-apm/utility/spark-clock/icon.png",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns only localIcon when a remote fallback URL cannot be built", async () => {
|
||||
const pkgname = "spark-reader";
|
||||
const applicationsDirectory = "/usr/share/applications";
|
||||
const desktopPath = `${applicationsDirectory}/reader-launcher.desktop`;
|
||||
const iconPath = `/usr/share/pixmaps/${pkgname}.png`;
|
||||
const { resolveUpdateItemIcons } = await loadIconsModule({
|
||||
directories: {
|
||||
[applicationsDirectory]: ["reader-launcher.desktop"],
|
||||
},
|
||||
files: {
|
||||
[desktopPath]: `[Desktop Entry]\nName=Spark Reader\nIcon=${iconPath}\n`,
|
||||
[iconPath]: "png",
|
||||
},
|
||||
packageFiles: {
|
||||
[pkgname]: [desktopPath],
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveUpdateItemIcons({
|
||||
pkgname,
|
||||
source: "aptss",
|
||||
currentVersion: "1.0.0",
|
||||
nextVersion: "2.0.0",
|
||||
}),
|
||||
).toEqual({
|
||||
localIcon: iconPath,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns an empty object when neither local nor remote icons are available", async () => {
|
||||
const { resolveUpdateItemIcons } = await loadIconsModule({});
|
||||
|
||||
expect(
|
||||
resolveUpdateItemIcons({
|
||||
pkgname: "spark-empty",
|
||||
source: "aptss",
|
||||
currentVersion: "1.0.0",
|
||||
nextVersion: "2.0.0",
|
||||
}),
|
||||
).toEqual({});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `npm run test -- --run src/__tests__/unit/update-center/icons.test.ts`
|
||||
|
||||
Expected: FAIL because `resolveUpdateItemIcon()` still returns a string and `resolveUpdateItemIcons()` does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
Update `electron/main/backend/update-center/types.ts` so the interface defines the two source fields instead of `icon`:
|
||||
|
||||
```ts
|
||||
export interface UpdateCenterItem {
|
||||
pkgname: string;
|
||||
source: UpdateSource;
|
||||
currentVersion: string;
|
||||
nextVersion: string;
|
||||
arch?: string;
|
||||
category?: string;
|
||||
localIcon?: string;
|
||||
remoteIcon?: string;
|
||||
ignored?: boolean;
|
||||
downloadUrl?: string;
|
||||
fileName?: string;
|
||||
size?: number;
|
||||
sha512?: string;
|
||||
isMigration?: boolean;
|
||||
migrationSource?: UpdateSource;
|
||||
migrationTarget?: UpdateSource;
|
||||
aptssVersion?: string;
|
||||
}
|
||||
```
|
||||
|
||||
Replace the old single-result helper at the end of `electron/main/backend/update-center/icons.ts` with this code:
|
||||
|
||||
```ts
|
||||
export interface UpdateItemIcons {
|
||||
localIcon?: string;
|
||||
remoteIcon?: string;
|
||||
}
|
||||
|
||||
export const resolveUpdateItemIcons = (
|
||||
item: UpdateCenterItem,
|
||||
): UpdateItemIcons => {
|
||||
const localIcon =
|
||||
item.source === "aptss"
|
||||
? resolveDesktopIcon(item.pkgname)
|
||||
: resolveApmIcon(item.pkgname);
|
||||
const remoteIcon =
|
||||
buildRemoteFallbackIconUrl({
|
||||
pkgname: item.pkgname,
|
||||
source: item.source,
|
||||
arch: item.arch,
|
||||
category: item.category,
|
||||
}) || undefined;
|
||||
|
||||
return {
|
||||
...(localIcon ? { localIcon } : {}),
|
||||
...(remoteIcon ? { remoteIcon } : {}),
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
Keep `resolveDesktopIcon()`, `resolveApmIcon()`, and `buildRemoteFallbackIconUrl()` unchanged.
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `npm run test -- --run src/__tests__/unit/update-center/icons.test.ts`
|
||||
|
||||
Expected: PASS with the updated icon helper tests green.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add electron/main/backend/update-center/types.ts electron/main/backend/update-center/icons.ts src/__tests__/unit/update-center/icons.test.ts
|
||||
git commit -m "fix(update-center): split local and remote icon sources"
|
||||
```
|
||||
|
||||
### Task 2: Propagate Icon Sources Through Loaded Items And Service Snapshots
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `electron/main/backend/update-center/index.ts`
|
||||
- Modify: `electron/main/backend/update-center/service.ts`
|
||||
- Modify: `src/global/typedefinition.ts`
|
||||
- Test: `src/__tests__/unit/update-center/load-items.test.ts`
|
||||
- Test: `src/__tests__/unit/update-center/registerUpdateCenter.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Update the expected item snapshots in `src/__tests__/unit/update-center/load-items.test.ts` from `icon` to `remoteIcon`:
|
||||
|
||||
```ts
|
||||
expect(result.items).toContainEqual({
|
||||
pkgname: "spark-weather",
|
||||
source: "apm",
|
||||
currentVersion: "1.5.0",
|
||||
nextVersion: "3.0.0",
|
||||
arch: "amd64",
|
||||
category: "tools",
|
||||
remoteIcon:
|
||||
"https://erotica.spark-app.store/amd64-apm/tools/spark-weather/icon.png",
|
||||
downloadUrl: "https://example.invalid/spark-weather_3.0.0_amd64.deb",
|
||||
fileName: "spark-weather_3.0.0_amd64.deb",
|
||||
size: 123456,
|
||||
sha512: "deadbeef",
|
||||
isMigration: true,
|
||||
migrationSource: "aptss",
|
||||
migrationTarget: "apm",
|
||||
aptssVersion: "2.0.0",
|
||||
});
|
||||
```
|
||||
|
||||
```ts
|
||||
expect(result.items).toEqual([
|
||||
{
|
||||
pkgname: "spark-notes",
|
||||
source: "aptss",
|
||||
currentVersion: "1.0.0",
|
||||
nextVersion: "2.0.0",
|
||||
arch: "amd64",
|
||||
category: "office",
|
||||
remoteIcon:
|
||||
"https://erotica.spark-app.store/amd64-store/office/spark-notes/icon.png",
|
||||
},
|
||||
]);
|
||||
```
|
||||
|
||||
```ts
|
||||
expect(secondResult.items).toEqual([
|
||||
{
|
||||
pkgname: "spark-notes",
|
||||
source: "aptss",
|
||||
currentVersion: "1.0.0",
|
||||
nextVersion: "2.0.0",
|
||||
arch: "amd64",
|
||||
category: "office",
|
||||
remoteIcon:
|
||||
"https://erotica.spark-app.store/amd64-store/office/spark-notes/icon.png",
|
||||
},
|
||||
]);
|
||||
```
|
||||
|
||||
```ts
|
||||
expect(result.items).toEqual([
|
||||
{
|
||||
pkgname: "spark-notes",
|
||||
source: "aptss",
|
||||
currentVersion: "1.0.0",
|
||||
nextVersion: "2.0.0",
|
||||
arch: "amd64",
|
||||
category: "office",
|
||||
remoteIcon:
|
||||
"https://erotica.spark-app.store/amd64-store/office/spark-notes/icon.png",
|
||||
},
|
||||
]);
|
||||
```
|
||||
|
||||
Replace the icon-preservation test in `src/__tests__/unit/update-center/registerUpdateCenter.test.ts` with:
|
||||
|
||||
```ts
|
||||
it("service task snapshots keep localIcon and remoteIcon for queued work", async () => {
|
||||
const service = createUpdateCenterService({
|
||||
loadItems: async () => [
|
||||
{
|
||||
...createItem(),
|
||||
localIcon: "/icons/weather.png",
|
||||
remoteIcon: "https://example.com/weather.png",
|
||||
},
|
||||
],
|
||||
createTaskRunner: (queue: UpdateCenterQueue) => ({
|
||||
cancelActiveTask: vi.fn(),
|
||||
runNextTask: async () => {
|
||||
const task = queue.getNextQueuedTask();
|
||||
if (!task) {
|
||||
return null;
|
||||
}
|
||||
|
||||
queue.markActiveTask(task.id, "installing");
|
||||
queue.finishTask(task.id, "completed");
|
||||
return task;
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await service.refresh();
|
||||
await service.start(["aptss:spark-weather"]);
|
||||
|
||||
expect(service.getState().tasks).toMatchObject([
|
||||
{
|
||||
taskKey: "aptss:spark-weather",
|
||||
localIcon: "/icons/weather.png",
|
||||
remoteIcon: "https://example.com/weather.png",
|
||||
status: "completed",
|
||||
},
|
||||
]);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `npm run test -- --run src/__tests__/unit/update-center/load-items.test.ts src/__tests__/unit/update-center/registerUpdateCenter.test.ts`
|
||||
|
||||
Expected: FAIL because the loader and service snapshots still publish `icon` instead of `localIcon` / `remoteIcon`.
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
Update the icon enrichment function in `electron/main/backend/update-center/index.ts`:
|
||||
|
||||
```ts
|
||||
import { resolveUpdateItemIcons } from "./icons";
|
||||
|
||||
const enrichItemIcons = (items: UpdateCenterItem[]): UpdateCenterItem[] => {
|
||||
return items.map((item) => {
|
||||
const { localIcon, remoteIcon } = resolveUpdateItemIcons(item);
|
||||
if (!localIcon && !remoteIcon) {
|
||||
return item;
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
...(localIcon ? { localIcon } : {}),
|
||||
...(remoteIcon ? { remoteIcon } : {}),
|
||||
};
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
Update the renderer-facing item/task types and `toState()` mapping in `electron/main/backend/update-center/service.ts`:
|
||||
|
||||
```ts
|
||||
export interface UpdateCenterServiceItem {
|
||||
taskKey: string;
|
||||
packageName: string;
|
||||
displayName: string;
|
||||
currentVersion: string;
|
||||
newVersion: string;
|
||||
source: UpdateSource;
|
||||
localIcon?: string;
|
||||
remoteIcon?: string;
|
||||
ignored?: boolean;
|
||||
downloadUrl?: string;
|
||||
fileName?: string;
|
||||
size?: number;
|
||||
sha512?: string;
|
||||
isMigration?: boolean;
|
||||
migrationSource?: UpdateSource;
|
||||
migrationTarget?: UpdateSource;
|
||||
aptssVersion?: string;
|
||||
}
|
||||
|
||||
export interface UpdateCenterServiceTask {
|
||||
taskKey: string;
|
||||
packageName: string;
|
||||
source: UpdateSource;
|
||||
localIcon?: string;
|
||||
remoteIcon?: string;
|
||||
status: UpdateCenterQueueSnapshot["tasks"][number]["status"];
|
||||
progress: number;
|
||||
logs: UpdateCenterQueueSnapshot["tasks"][number]["logs"];
|
||||
errorMessage: string;
|
||||
}
|
||||
|
||||
const toState = (
|
||||
snapshot: UpdateCenterQueueSnapshot,
|
||||
): UpdateCenterServiceState => ({
|
||||
items: snapshot.items.map((item) => ({
|
||||
taskKey: getTaskKey(item),
|
||||
packageName: item.pkgname,
|
||||
displayName: item.pkgname,
|
||||
currentVersion: item.currentVersion,
|
||||
newVersion: item.nextVersion,
|
||||
source: item.source,
|
||||
localIcon: item.localIcon,
|
||||
remoteIcon: item.remoteIcon,
|
||||
ignored: item.ignored,
|
||||
downloadUrl: item.downloadUrl,
|
||||
fileName: item.fileName,
|
||||
size: item.size,
|
||||
sha512: item.sha512,
|
||||
isMigration: item.isMigration,
|
||||
migrationSource: item.migrationSource,
|
||||
migrationTarget: item.migrationTarget,
|
||||
aptssVersion: item.aptssVersion,
|
||||
})),
|
||||
tasks: snapshot.tasks.map((task) => ({
|
||||
taskKey: getTaskKey(task.item),
|
||||
packageName: task.pkgname,
|
||||
source: task.item.source,
|
||||
localIcon: task.item.localIcon,
|
||||
remoteIcon: task.item.remoteIcon,
|
||||
status: task.status,
|
||||
progress: task.progress,
|
||||
logs: task.logs.map((log) => ({ ...log })),
|
||||
errorMessage: task.error ?? "",
|
||||
})),
|
||||
warnings: [...snapshot.warnings],
|
||||
hasRunningTasks: snapshot.hasRunningTasks,
|
||||
});
|
||||
```
|
||||
|
||||
Update the update-center renderer types in `src/global/typedefinition.ts`:
|
||||
|
||||
```ts
|
||||
export interface UpdateCenterItem {
|
||||
taskKey: string;
|
||||
packageName: string;
|
||||
displayName: string;
|
||||
currentVersion: string;
|
||||
newVersion: string;
|
||||
source: UpdateSource;
|
||||
localIcon?: string;
|
||||
remoteIcon?: string;
|
||||
ignored?: boolean;
|
||||
downloadUrl?: string;
|
||||
fileName?: string;
|
||||
size?: number;
|
||||
sha512?: string;
|
||||
isMigration?: boolean;
|
||||
migrationSource?: UpdateSource;
|
||||
migrationTarget?: UpdateSource;
|
||||
aptssVersion?: string;
|
||||
}
|
||||
|
||||
export interface UpdateCenterTaskState {
|
||||
taskKey: string;
|
||||
packageName: string;
|
||||
source: UpdateSource;
|
||||
localIcon?: string;
|
||||
remoteIcon?: string;
|
||||
status: UpdateCenterTaskStatus;
|
||||
progress: number;
|
||||
logs: Array<{ time: number; message: string }>;
|
||||
errorMessage: string;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `npm run test -- --run src/__tests__/unit/update-center/load-items.test.ts src/__tests__/unit/update-center/registerUpdateCenter.test.ts`
|
||||
|
||||
Expected: PASS with the loader and service tests green.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add electron/main/backend/update-center/index.ts electron/main/backend/update-center/service.ts src/global/typedefinition.ts src/__tests__/unit/update-center/load-items.test.ts src/__tests__/unit/update-center/registerUpdateCenter.test.ts
|
||||
git commit -m "refactor(update-center): propagate icon fallback fields"
|
||||
```
|
||||
|
||||
### Task 3: Implement Renderer Fallback Order In UpdateCenterItem.vue
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/components/update-center/UpdateCenterItem.vue`
|
||||
- Test: `src/__tests__/unit/update-center/UpdateCenterItem.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Replace the contents of `src/__tests__/unit/update-center/UpdateCenterItem.test.ts` with:
|
||||
|
||||
```ts
|
||||
import { fireEvent, render, screen } from "@testing-library/vue";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import UpdateCenterItem from "@/components/update-center/UpdateCenterItem.vue";
|
||||
import type {
|
||||
UpdateCenterItem as UpdateCenterItemData,
|
||||
UpdateCenterTaskState,
|
||||
} from "@/global/typedefinition";
|
||||
|
||||
const createItem = (
|
||||
overrides: Partial<UpdateCenterItemData> = {},
|
||||
): UpdateCenterItemData => ({
|
||||
taskKey: "aptss:spark-weather",
|
||||
packageName: "spark-weather",
|
||||
displayName: "Spark Weather",
|
||||
currentVersion: "1.0.0",
|
||||
newVersion: "2.0.0",
|
||||
source: "aptss",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createTask = (
|
||||
overrides: Partial<UpdateCenterTaskState> = {},
|
||||
): UpdateCenterTaskState => ({
|
||||
taskKey: "aptss:spark-weather",
|
||||
packageName: "spark-weather",
|
||||
source: "aptss",
|
||||
status: "downloading",
|
||||
progress: 42,
|
||||
logs: [],
|
||||
errorMessage: "",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("UpdateCenterItem", () => {
|
||||
it("renders localIcon first when both icon sources exist", () => {
|
||||
render(UpdateCenterItem, {
|
||||
props: {
|
||||
item: createItem({
|
||||
localIcon: "/usr/share/pixmaps/spark-weather.png",
|
||||
remoteIcon: "https://example.com/spark-weather.png",
|
||||
}),
|
||||
task: createTask(),
|
||||
selected: false,
|
||||
},
|
||||
});
|
||||
|
||||
const icon = screen.getByRole("img", { name: "Spark Weather 图标" });
|
||||
|
||||
expect(icon).toHaveAttribute(
|
||||
"src",
|
||||
"file:///usr/share/pixmaps/spark-weather.png",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to remoteIcon when localIcon fails", async () => {
|
||||
render(UpdateCenterItem, {
|
||||
props: {
|
||||
item: createItem({
|
||||
localIcon: "/usr/share/pixmaps/spark-weather.png",
|
||||
remoteIcon: "https://example.com/spark-weather.png",
|
||||
}),
|
||||
task: createTask(),
|
||||
selected: false,
|
||||
},
|
||||
});
|
||||
|
||||
const icon = screen.getByRole("img", { name: "Spark Weather 图标" });
|
||||
|
||||
await fireEvent.error(icon);
|
||||
|
||||
expect(icon).toHaveAttribute(
|
||||
"src",
|
||||
"https://example.com/spark-weather.png",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the placeholder after localIcon and remoteIcon both fail", async () => {
|
||||
render(UpdateCenterItem, {
|
||||
props: {
|
||||
item: createItem({
|
||||
localIcon: "/usr/share/pixmaps/spark-weather.png",
|
||||
remoteIcon: "https://example.com/spark-weather.png",
|
||||
}),
|
||||
task: createTask(),
|
||||
selected: false,
|
||||
},
|
||||
});
|
||||
|
||||
const icon = screen.getByRole("img", { name: "Spark Weather 图标" });
|
||||
|
||||
await fireEvent.error(icon);
|
||||
await fireEvent.error(icon);
|
||||
|
||||
expect(icon.getAttribute("src")).toContain("data:image/svg+xml");
|
||||
expect(icon.getAttribute("src")).not.toContain(
|
||||
"https://example.com/spark-weather.png",
|
||||
);
|
||||
});
|
||||
|
||||
it("restarts from localIcon when a new item is rendered", async () => {
|
||||
const { rerender } = render(UpdateCenterItem, {
|
||||
props: {
|
||||
item: createItem({
|
||||
localIcon: "/usr/share/pixmaps/spark-weather.png",
|
||||
remoteIcon: "https://example.com/spark-weather.png",
|
||||
}),
|
||||
task: createTask(),
|
||||
selected: false,
|
||||
},
|
||||
});
|
||||
|
||||
const firstIcon = screen.getByRole("img", { name: "Spark Weather 图标" });
|
||||
|
||||
await fireEvent.error(firstIcon);
|
||||
|
||||
expect(firstIcon).toHaveAttribute(
|
||||
"src",
|
||||
"https://example.com/spark-weather.png",
|
||||
);
|
||||
|
||||
await rerender({
|
||||
item: createItem({
|
||||
taskKey: "aptss:spark-clock",
|
||||
packageName: "spark-clock",
|
||||
displayName: "Spark Clock",
|
||||
localIcon: "/usr/share/pixmaps/spark-clock.png",
|
||||
remoteIcon: "https://example.com/spark-clock.png",
|
||||
}),
|
||||
task: createTask({
|
||||
taskKey: "aptss:spark-clock",
|
||||
packageName: "spark-clock",
|
||||
}),
|
||||
selected: false,
|
||||
});
|
||||
|
||||
const nextIcon = screen.getByRole("img", { name: "Spark Clock 图标" });
|
||||
|
||||
expect(nextIcon).toHaveAttribute(
|
||||
"src",
|
||||
"file:///usr/share/pixmaps/spark-clock.png",
|
||||
);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `npm run test -- --run src/__tests__/unit/update-center/UpdateCenterItem.test.ts`
|
||||
|
||||
Expected: FAIL because the component still reads `item.icon` and goes straight from a single failed image to the placeholder.
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
Replace the `<script setup>` block in `src/components/update-center/UpdateCenterItem.vue` with:
|
||||
|
||||
```ts
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
|
||||
import type {
|
||||
UpdateCenterItem,
|
||||
UpdateCenterTaskState,
|
||||
} from "@/global/typedefinition";
|
||||
|
||||
const props = defineProps<{
|
||||
item: UpdateCenterItem;
|
||||
task?: UpdateCenterTaskState;
|
||||
selected: boolean;
|
||||
}>();
|
||||
|
||||
const PLACEHOLDER_ICON =
|
||||
'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"%3E%3Crect width="48" height="48" rx="12" fill="%23e2e8f0"/%3E%3Cpath d="M17 31h14v2H17zm3-12h8a2 2 0 0 1 2 2v8H18v-8a2 2 0 0 1 2-2" fill="%2394a3b8"/%3E%3C/svg%3E';
|
||||
const currentIconIndex = ref(0);
|
||||
const allCandidatesFailed = ref(false);
|
||||
|
||||
defineEmits<{
|
||||
(e: "toggle-selection"): void;
|
||||
}>();
|
||||
|
||||
const normalizeIconSrc = (icon: string): string => {
|
||||
if (/^[a-z]+:\/\//i.test(icon)) {
|
||||
return icon;
|
||||
}
|
||||
|
||||
return icon.startsWith("/") ? `file://${icon}` : icon;
|
||||
};
|
||||
|
||||
const iconCandidates = computed(() => {
|
||||
return [props.item.localIcon, props.item.remoteIcon]
|
||||
.filter((icon): icon is string => Boolean(icon && icon.trim().length > 0))
|
||||
.map((icon) => normalizeIconSrc(icon));
|
||||
});
|
||||
|
||||
const resetIconFallback = () => {
|
||||
currentIconIndex.value = 0;
|
||||
allCandidatesFailed.value = false;
|
||||
};
|
||||
|
||||
const handleIconError = () => {
|
||||
if (currentIconIndex.value < iconCandidates.value.length - 1) {
|
||||
currentIconIndex.value += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
allCandidatesFailed.value = true;
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.item,
|
||||
() => {
|
||||
resetIconFallback();
|
||||
},
|
||||
);
|
||||
|
||||
const iconSrc = computed(() => {
|
||||
if (allCandidatesFailed.value || iconCandidates.value.length === 0) {
|
||||
return PLACEHOLDER_ICON;
|
||||
}
|
||||
|
||||
return iconCandidates.value[currentIconIndex.value] ?? PLACEHOLDER_ICON;
|
||||
});
|
||||
|
||||
const sourceLabel = computed(() => {
|
||||
return props.item.source === "apm" ? "APM" : "传统deb";
|
||||
});
|
||||
|
||||
const statusLabel = computed(() => {
|
||||
switch (props.task?.status) {
|
||||
case "downloading":
|
||||
return "下载中";
|
||||
case "installing":
|
||||
return "安装中";
|
||||
case "completed":
|
||||
return "已完成";
|
||||
case "failed":
|
||||
return "失败";
|
||||
case "cancelled":
|
||||
return "已取消";
|
||||
default:
|
||||
return "待处理";
|
||||
}
|
||||
});
|
||||
|
||||
const showProgress = computed(() => {
|
||||
return (
|
||||
props.task?.status === "downloading" || props.task?.status === "installing"
|
||||
);
|
||||
});
|
||||
|
||||
const progressText = computed(() => `${props.task?.progress ?? 0}%`);
|
||||
const progressStyle = computed(() => ({ width: progressText.value }));
|
||||
</script>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `npm run test -- --run src/__tests__/unit/update-center/UpdateCenterItem.test.ts`
|
||||
|
||||
Expected: PASS with all fallback-order component tests green.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/components/update-center/UpdateCenterItem.vue src/__tests__/unit/update-center/UpdateCenterItem.test.ts
|
||||
git commit -m "fix(update-center): cascade icon fallback in renderer"
|
||||
```
|
||||
|
||||
### Task 4: Verify The Full Change Set
|
||||
|
||||
**Files:**
|
||||
|
||||
- Verify only: `electron/main/backend/update-center/types.ts`
|
||||
- Verify only: `electron/main/backend/update-center/icons.ts`
|
||||
- Verify only: `electron/main/backend/update-center/index.ts`
|
||||
- Verify only: `electron/main/backend/update-center/service.ts`
|
||||
- Verify only: `src/global/typedefinition.ts`
|
||||
- Verify only: `src/components/update-center/UpdateCenterItem.vue`
|
||||
- Verify only: `src/__tests__/unit/update-center/icons.test.ts`
|
||||
- Verify only: `src/__tests__/unit/update-center/load-items.test.ts`
|
||||
- Verify only: `src/__tests__/unit/update-center/registerUpdateCenter.test.ts`
|
||||
- Verify only: `src/__tests__/unit/update-center/UpdateCenterItem.test.ts`
|
||||
|
||||
- [ ] **Step 1: Run the focused update-center test suite**
|
||||
|
||||
Run: `npm run test -- --run src/__tests__/unit/update-center/icons.test.ts src/__tests__/unit/update-center/load-items.test.ts src/__tests__/unit/update-center/registerUpdateCenter.test.ts src/__tests__/unit/update-center/UpdateCenterItem.test.ts`
|
||||
|
||||
Expected: PASS with all four update-center suites green.
|
||||
|
||||
- [ ] **Step 2: Run the formatter**
|
||||
|
||||
Run: `npm run format`
|
||||
|
||||
Expected: command exits 0 after formatting the touched files.
|
||||
|
||||
- [ ] **Step 3: Run lint**
|
||||
|
||||
Run: `npm run lint`
|
||||
|
||||
Expected: PASS with no ESLint or Prettier violations.
|
||||
|
||||
- [ ] **Step 4: Run the production build**
|
||||
|
||||
Run: `npm run build`
|
||||
|
||||
Expected: PASS with Vite/Electron build output generated successfully and no TypeScript errors.
|
||||
@@ -0,0 +1,529 @@
|
||||
# Update Center Migration Strategy Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make update-center behavior follow installed-source-aware rules, including aptss-to-apm migration as a single visible update that removes the aptss package before installing the apm version.
|
||||
|
||||
**Architecture:** Keep the existing update-center pipeline, but change it at three narrow seams: source merging in `query.ts`, task payload creation in `service.ts`, and migration execution in the main-process install path. The renderer keeps showing update items and download queue entries, while the main process becomes the only place that performs the ordered `aptss remove -> apm install` migration.
|
||||
|
||||
**Tech Stack:** TypeScript, Electron IPC, Vue 3, Vitest
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Installed-Source Merge Rules
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `electron/main/backend/update-center/query.ts:325-374`
|
||||
- Test: `src/__tests__/unit/update-center/query.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing merge-rule tests**
|
||||
|
||||
Add these tests to `src/__tests__/unit/update-center/query.test.ts` next to the existing `mergeUpdateSources()` coverage:
|
||||
|
||||
```ts
|
||||
it("returns only the migration item when only aptss is installed and apm has a higher version", () => {
|
||||
const merged = mergeUpdateSources(
|
||||
[
|
||||
{
|
||||
pkgname: "spark-weather",
|
||||
source: "aptss",
|
||||
currentVersion: "1.9.0",
|
||||
nextVersion: "2.0.0",
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
pkgname: "spark-weather",
|
||||
source: "apm",
|
||||
currentVersion: "1.8.0",
|
||||
nextVersion: "3.0.0",
|
||||
},
|
||||
],
|
||||
new Map([["spark-weather", { aptss: true, apm: false }]]),
|
||||
);
|
||||
|
||||
expect(merged).toEqual([
|
||||
{
|
||||
pkgname: "spark-weather",
|
||||
source: "apm",
|
||||
currentVersion: "1.8.0",
|
||||
nextVersion: "3.0.0",
|
||||
isMigration: true,
|
||||
migrationSource: "aptss",
|
||||
migrationTarget: "apm",
|
||||
aptssVersion: "2.0.0",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns only the aptss item when only aptss is installed and apm is not newer", () => {
|
||||
const merged = mergeUpdateSources(
|
||||
[
|
||||
{
|
||||
pkgname: "spark-notes",
|
||||
source: "aptss",
|
||||
currentVersion: "1.0.0",
|
||||
nextVersion: "2.0.0",
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
pkgname: "spark-notes",
|
||||
source: "apm",
|
||||
currentVersion: "1.0.0",
|
||||
nextVersion: "1.5.0",
|
||||
},
|
||||
],
|
||||
new Map([["spark-notes", { aptss: true, apm: false }]]),
|
||||
);
|
||||
|
||||
expect(merged).toEqual([
|
||||
{
|
||||
pkgname: "spark-notes",
|
||||
source: "aptss",
|
||||
currentVersion: "1.0.0",
|
||||
nextVersion: "2.0.0",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns only the apm item when only apm is installed", () => {
|
||||
const merged = mergeUpdateSources(
|
||||
[
|
||||
{
|
||||
pkgname: "spark-player",
|
||||
source: "aptss",
|
||||
currentVersion: "1.0.0",
|
||||
nextVersion: "2.0.0",
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
pkgname: "spark-player",
|
||||
source: "apm",
|
||||
currentVersion: "1.1.0",
|
||||
nextVersion: "3.0.0",
|
||||
},
|
||||
],
|
||||
new Map([["spark-player", { aptss: false, apm: true }]]),
|
||||
);
|
||||
|
||||
expect(merged).toEqual([
|
||||
{
|
||||
pkgname: "spark-player",
|
||||
source: "apm",
|
||||
currentVersion: "1.1.0",
|
||||
nextVersion: "3.0.0",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns both items when aptss and apm are both installed", () => {
|
||||
const merged = mergeUpdateSources(
|
||||
[
|
||||
{
|
||||
pkgname: "spark-browser",
|
||||
source: "aptss",
|
||||
currentVersion: "10.0",
|
||||
nextVersion: "11.0",
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
pkgname: "spark-browser",
|
||||
source: "apm",
|
||||
currentVersion: "11.0",
|
||||
nextVersion: "12.0",
|
||||
},
|
||||
],
|
||||
new Map([["spark-browser", { aptss: true, apm: true }]]),
|
||||
);
|
||||
|
||||
expect(merged).toEqual([
|
||||
{
|
||||
pkgname: "spark-browser",
|
||||
source: "aptss",
|
||||
currentVersion: "10.0",
|
||||
nextVersion: "11.0",
|
||||
},
|
||||
{
|
||||
pkgname: "spark-browser",
|
||||
source: "apm",
|
||||
currentVersion: "11.0",
|
||||
nextVersion: "12.0",
|
||||
},
|
||||
]);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `npm run test -- --run src/__tests__/unit/update-center/query.test.ts`
|
||||
Expected: FAIL because the current implementation still returns both the migration item and the aptss item for the aptss-only migration case, and still returns both sources in the apm-only case.
|
||||
|
||||
- [ ] **Step 3: Write the minimal merge logic**
|
||||
|
||||
Update `electron/main/backend/update-center/query.ts` so `mergeUpdateSources()` uses installed-source-aware branching instead of unconditional double inclusion. Replace the body with this implementation shape:
|
||||
|
||||
```ts
|
||||
export const mergeUpdateSources = (
|
||||
aptssItems: UpdateCenterItem[],
|
||||
apmItems: UpdateCenterItem[],
|
||||
installedSources: Map<string, InstalledSourceState>,
|
||||
): UpdateCenterItem[] => {
|
||||
const aptssMap = new Map(aptssItems.map((item) => [item.pkgname, item]));
|
||||
const apmMap = new Map(apmItems.map((item) => [item.pkgname, item]));
|
||||
const pkgnames = new Set([...aptssMap.keys(), ...apmMap.keys()]);
|
||||
const merged: UpdateCenterItem[] = [];
|
||||
|
||||
for (const pkgname of pkgnames) {
|
||||
const aptssItem = aptssMap.get(pkgname);
|
||||
const apmItem = apmMap.get(pkgname);
|
||||
const installedState = installedSources.get(pkgname);
|
||||
|
||||
if (installedState?.aptss === true && installedState.apm === false) {
|
||||
if (aptssItem && apmItem) {
|
||||
if (compareVersions(apmItem.nextVersion, aptssItem.nextVersion) > 0) {
|
||||
merged.push({
|
||||
...apmItem,
|
||||
isMigration: true,
|
||||
migrationSource: "aptss",
|
||||
migrationTarget: "apm",
|
||||
aptssVersion: aptssItem.nextVersion,
|
||||
});
|
||||
} else {
|
||||
merged.push(aptssItem);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (aptssItem) {
|
||||
merged.push(aptssItem);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (installedState?.aptss === false && installedState.apm === true) {
|
||||
if (apmItem) {
|
||||
merged.push(apmItem);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (installedState?.aptss === true && installedState.apm === true) {
|
||||
if (aptssItem) merged.push(aptssItem);
|
||||
if (apmItem) merged.push(apmItem);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (aptssItem) merged.push(aptssItem);
|
||||
if (apmItem) merged.push(apmItem);
|
||||
}
|
||||
|
||||
return merged;
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `npm run test -- --run src/__tests__/unit/update-center/query.test.ts`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/__tests__/unit/update-center/query.test.ts electron/main/backend/update-center/query.ts
|
||||
git commit -m "fix(update-center): apply installed-source merge rules"
|
||||
```
|
||||
|
||||
### Task 2: Migration Payload and Main-Process Execution
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `electron/main/backend/update-center/service.ts:191-245`
|
||||
- Modify: `src/global/typedefinition.ts:29-54`
|
||||
- Modify: `src/modules/updateCenter.ts:148-205`
|
||||
- Modify: `electron/main/backend/install-manager.ts:251-299`
|
||||
- Test: `src/__tests__/unit/update-center/registerUpdateCenter.test.ts`
|
||||
- Test: `src/__tests__/unit/update-center/task-runner.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing IPC payload test**
|
||||
|
||||
Add this test to `src/__tests__/unit/update-center/registerUpdateCenter.test.ts` near the existing `service.start()` coverage:
|
||||
|
||||
```ts
|
||||
it("sends migration metadata to the main install queue", async () => {
|
||||
const send = vi.fn();
|
||||
electronMock.getAllWindows.mockReturnValue([{ webContents: { send } }]);
|
||||
|
||||
const service = createUpdateCenterService({
|
||||
loadItems: async () => [
|
||||
{
|
||||
...createItem(),
|
||||
source: "apm",
|
||||
isMigration: true,
|
||||
migrationSource: "aptss",
|
||||
migrationTarget: "apm",
|
||||
fileName: "spark-weather_3.0.0_amd64.deb",
|
||||
downloadUrl: "https://example.invalid/spark-weather_3.0.0_amd64.deb",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await service.refresh();
|
||||
await service.start(["apm:spark-weather"]);
|
||||
|
||||
expect(send).toHaveBeenCalledWith(
|
||||
"queue-install",
|
||||
JSON.stringify(
|
||||
expect.objectContaining({
|
||||
pkgname: "spark-weather",
|
||||
origin: "apm",
|
||||
upgradeOnly: true,
|
||||
isMigration: true,
|
||||
migrationSource: "aptss",
|
||||
migrationTarget: "apm",
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write the failing migration install test**
|
||||
|
||||
Add this test to `src/__tests__/unit/update-center/task-runner.test.ts` after the direct install command tests:
|
||||
|
||||
```ts
|
||||
it("runs aptss remove before apm ssinstall for migration items", async () => {
|
||||
childProcessMock.spawnCalls.length = 0;
|
||||
|
||||
await installUpdateItem({
|
||||
item: {
|
||||
...createApmItem(),
|
||||
isMigration: true,
|
||||
migrationSource: "aptss",
|
||||
migrationTarget: "apm",
|
||||
},
|
||||
filePath: "/tmp/spark-player.deb",
|
||||
superUserCmd: "/usr/bin/pkexec",
|
||||
});
|
||||
|
||||
expect(childProcessMock.spawnCalls).toEqual([
|
||||
{
|
||||
command: "/usr/bin/pkexec",
|
||||
args: [
|
||||
"/opt/spark-store/extras/shell-caller.sh",
|
||||
"aptss",
|
||||
"remove",
|
||||
"spark-player",
|
||||
],
|
||||
},
|
||||
{
|
||||
command: "/usr/bin/pkexec",
|
||||
args: [
|
||||
"/opt/spark-store/extras/shell-caller.sh",
|
||||
"apm",
|
||||
"ssinstall",
|
||||
"/tmp/spark-player.deb",
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run tests to verify they fail**
|
||||
|
||||
Run: `npm run test -- --run src/__tests__/unit/update-center/registerUpdateCenter.test.ts src/__tests__/unit/update-center/task-runner.test.ts`
|
||||
Expected: FAIL because the queue-install payload does not include migration metadata yet, and the install path still runs only the apm install command.
|
||||
|
||||
- [ ] **Step 4: Extend the task payload types**
|
||||
|
||||
Add these optional fields to `DownloadItem`-adjacent transport types in `src/global/typedefinition.ts` or the local payload interface that already carries queue-install data:
|
||||
|
||||
```ts
|
||||
isMigration?: boolean;
|
||||
migrationSource?: "aptss" | "apm";
|
||||
migrationTarget?: "aptss" | "apm";
|
||||
```
|
||||
|
||||
If the queue payload is not typed centrally, create a narrow local type in `electron/main/backend/update-center/service.ts` and a matching parsing shape in `electron/main/backend/install-manager.ts`.
|
||||
|
||||
- [ ] **Step 5: Send migration metadata from the update-center service**
|
||||
|
||||
Change `installTaskData` in `electron/main/backend/update-center/service.ts` to include the migration fields when present:
|
||||
|
||||
```ts
|
||||
const installTaskData = {
|
||||
id: updateTaskId,
|
||||
pkgname: item.pkgname,
|
||||
metalinkUrl,
|
||||
filename: item.fileName,
|
||||
upgradeOnly: true,
|
||||
origin: item.source === "apm" ? "apm" : "spark",
|
||||
retry: false,
|
||||
isMigration: item.isMigration === true,
|
||||
migrationSource: item.migrationSource,
|
||||
migrationTarget: item.migrationTarget,
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Preserve migration state in the renderer queue item**
|
||||
|
||||
Extend the temporary queue item created in `src/modules/updateCenter.ts` so migration items show up as migration work instead of generic updates:
|
||||
|
||||
```ts
|
||||
logs: [
|
||||
{
|
||||
time: Date.now(),
|
||||
message: item.isMigration === true ? "开始迁移到 APM..." : "开始更新...",
|
||||
},
|
||||
],
|
||||
```
|
||||
|
||||
Also carry the same optional migration flags if the renderer-side queue type supports them.
|
||||
|
||||
- [ ] **Step 7: Implement ordered migration execution in the main process**
|
||||
|
||||
In the main install path used by update-center file installs, add a migration branch before the normal `origin === "apm"` handling. The shape should be:
|
||||
|
||||
```ts
|
||||
if (isMigration === true && migrationSource === "aptss" && origin === "apm") {
|
||||
const removeCommand = superUserCmd || SHELL_CALLER_PATH;
|
||||
const removeParams: string[] = [];
|
||||
if (superUserCmd) {
|
||||
removeParams.push(SHELL_CALLER_PATH);
|
||||
}
|
||||
removeParams.push("aptss", "remove", pkgname);
|
||||
|
||||
await runInstallCommand({
|
||||
command: removeCommand,
|
||||
args: removeParams,
|
||||
webContents,
|
||||
id,
|
||||
stageLabel: "迁移卸载旧版本",
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Then fall through to the existing apm install branch so the next command remains:
|
||||
|
||||
```ts
|
||||
execParams.push("apm");
|
||||
execParams.push("ssinstall", `${downloadDir}/${filename}`);
|
||||
```
|
||||
|
||||
Use the existing install-log/install-complete reporting path rather than inventing a second event system.
|
||||
|
||||
- [ ] **Step 8: Run tests to verify they pass**
|
||||
|
||||
Run: `npm run test -- --run src/__tests__/unit/update-center/registerUpdateCenter.test.ts src/__tests__/unit/update-center/task-runner.test.ts`
|
||||
Expected: PASS for the new migration payload and command-order tests
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add electron/main/backend/update-center/service.ts src/global/typedefinition.ts src/modules/updateCenter.ts electron/main/backend/install-manager.ts src/__tests__/unit/update-center/registerUpdateCenter.test.ts src/__tests__/unit/update-center/task-runner.test.ts
|
||||
git commit -m "feat(update-center): run aptss-to-apm migrations"
|
||||
```
|
||||
|
||||
### Task 3: Migration Confirmation Copy and Renderer Regression
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/components/update-center/UpdateCenterMigrationConfirm.vue`
|
||||
- Test: `src/__tests__/unit/update-center/UpdateCenterModal.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing migration-copy test**
|
||||
|
||||
Update `src/__tests__/unit/update-center/UpdateCenterModal.test.ts` so the migration confirmation assertion expects the new copy:
|
||||
|
||||
```ts
|
||||
it("renders migration confirmation copy explaining aptss removal and apm install", () => {
|
||||
const store = createStore({ hasRunningTasks: false });
|
||||
store.showMigrationConfirm.value = true;
|
||||
|
||||
render(UpdateCenterModal, {
|
||||
props: {
|
||||
show: true,
|
||||
store,
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByText("迁移确认")).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText(/会先卸载现有 aptss 版本,再安装 APM 版本/),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText(/后续更新将由 APM 管理/)).toBeTruthy();
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `npm run test -- --run src/__tests__/unit/update-center/UpdateCenterModal.test.ts`
|
||||
Expected: FAIL because the current modal copy only says that some deb updates will migrate to APM.
|
||||
|
||||
- [ ] **Step 3: Update the modal copy with the approved behavior**
|
||||
|
||||
Change the body text in `src/components/update-center/UpdateCenterMigrationConfirm.vue` to this:
|
||||
|
||||
```vue
|
||||
<p class="mt-2 text-sm text-slate-500 dark:text-slate-400">
|
||||
该应用将从传统 aptss 管理迁移到 APM 管理。迁移过程会先卸载现有 aptss 版本,再安装 APM 版本;迁移完成后,后续更新将由 APM 管理。
|
||||
</p>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `npm run test -- --run src/__tests__/unit/update-center/UpdateCenterModal.test.ts`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/components/update-center/UpdateCenterMigrationConfirm.vue src/__tests__/unit/update-center/UpdateCenterModal.test.ts
|
||||
git commit -m "docs(update-center): clarify migration confirmation"
|
||||
```
|
||||
|
||||
### Task 4: Final Verification
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: none
|
||||
- Test: `src/__tests__/unit/update-center/query.test.ts`
|
||||
- Test: `src/__tests__/unit/update-center/registerUpdateCenter.test.ts`
|
||||
- Test: `src/__tests__/unit/update-center/task-runner.test.ts`
|
||||
- Test: `src/__tests__/unit/update-center/UpdateCenterModal.test.ts`
|
||||
|
||||
- [ ] **Step 1: Run focused regression suite**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm run test -- --run src/__tests__/unit/update-center/query.test.ts src/__tests__/unit/update-center/registerUpdateCenter.test.ts src/__tests__/unit/update-center/task-runner.test.ts src/__tests__/unit/update-center/UpdateCenterModal.test.ts
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 2: Run lint if the touched files are lint-clean**
|
||||
|
||||
Run: `npm run lint`
|
||||
Expected: either PASS or the same known unrelated pre-existing lint failures already present in the branch. Do not claim a clean lint run unless the command output is actually clean.
|
||||
|
||||
- [ ] **Step 3: Review the final diff**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git diff -- electron/main/backend/update-center/query.ts electron/main/backend/update-center/service.ts electron/main/backend/install-manager.ts src/modules/updateCenter.ts src/components/update-center/UpdateCenterMigrationConfirm.vue src/__tests__/unit/update-center/query.test.ts src/__tests__/unit/update-center/registerUpdateCenter.test.ts src/__tests__/unit/update-center/task-runner.test.ts src/__tests__/unit/update-center/UpdateCenterModal.test.ts
|
||||
```
|
||||
|
||||
Expected: diff only covers merge rules, migration payload/execution, and migration confirmation copy.
|
||||
|
||||
- [ ] **Step 4: Commit final verification state if needed**
|
||||
|
||||
```bash
|
||||
git status --short
|
||||
```
|
||||
|
||||
If uncommitted changes remain from verification-only edits, either commit them with a focused message or fold them into the last task commit before handing off.
|
||||
@@ -0,0 +1,996 @@
|
||||
# Gitee Issue Bot Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Build a user-level `systemd`-driven issue bot that checks Spark Store Gitee issues every 6 hours, stores one ranked candidate locally, and only launches a new opencode window after explicit manual approval.
|
||||
|
||||
**Architecture:** Keep the implementation outside the Electron runtime by adding a small TypeScript script set under `scripts/issue-bot/`, with focused helpers for Gitee fetching, ranking, local state, approval, and opencode launching. Use user-cache state storage plus `systemd --user` service/timer units, and pass the `~/Desktop/spark-store` + `Erotica`-based worktree requirement into the generated opencode prompt instead of creating worktrees during polling.
|
||||
|
||||
**Tech Stack:** Node.js 22 with `--experimental-strip-types`, TypeScript strict mode, built-in `fetch`, Vitest, npm scripts, `systemd --user` units.
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
- Create: `scripts/issue-bot/lib/types.ts` — shared strict TypeScript types for normalized issues, ranking results, and persisted state.
|
||||
- Create: `scripts/issue-bot/lib/state.ts` — state file path resolution, JSON load/save, corruption backup, and default-state initialization.
|
||||
- Create: `scripts/issue-bot/lib/ranking.ts` — issue filtering, heuristic scoring, and candidate selection.
|
||||
- Create: `scripts/issue-bot/lib/gitee.ts` — fetch open issues from Gitee API first and normalize the response.
|
||||
- Create: `scripts/issue-bot/lib/opencode.ts` — build approval prompt and spawn a configured opencode command.
|
||||
- Create: `scripts/issue-bot/check-issues.ts` — one-shot polling entrypoint that updates `currentCandidate`.
|
||||
- Create: `scripts/issue-bot/approve-issue.ts` — manual approval entrypoint that launches opencode and marks the approved issue.
|
||||
- Create: `src/__tests__/unit/issue-bot/state.test.ts` — state initialization, backup, and save/load tests.
|
||||
- Create: `src/__tests__/unit/issue-bot/ranking.test.ts` — scoring, filtering, and candidate selection tests.
|
||||
- Create: `src/__tests__/unit/issue-bot/check-issues.test.ts` — polling orchestration tests using mocked fetch/state.
|
||||
- Create: `src/__tests__/unit/issue-bot/approve-issue.test.ts` — approval and opencode-launch orchestration tests.
|
||||
- Create: `src/__tests__/unit/issue-bot/packaging.test.ts` — npm script and systemd unit smoke tests.
|
||||
- Modify: `package.json` — add `issue-bot:check` and `issue-bot:approve` scripts.
|
||||
- Modify: `tsconfig.node.json` — include `scripts` for type-check coverage in build tooling.
|
||||
- Create: `extras/systemd/spark-store-issue-bot.service` — `oneshot` user service for polling.
|
||||
- Create: `extras/systemd/spark-store-issue-bot.timer` — six-hour persistent timer.
|
||||
|
||||
### Task 1: Add Shared Types and Local State Storage
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `scripts/issue-bot/lib/types.ts`
|
||||
- Create: `scripts/issue-bot/lib/state.ts`
|
||||
- Test: `src/__tests__/unit/issue-bot/state.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```ts
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
createDefaultIssueBotState,
|
||||
getIssueBotStatePath,
|
||||
loadIssueBotState,
|
||||
saveIssueBotState,
|
||||
} from "../../../../scripts/issue-bot/lib/state";
|
||||
|
||||
describe("issue-bot state", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
delete process.env.XDG_CACHE_HOME;
|
||||
});
|
||||
|
||||
it("uses the XDG cache directory when available", () => {
|
||||
process.env.XDG_CACHE_HOME = "/tmp/spark-cache";
|
||||
|
||||
expect(getIssueBotStatePath()).toBe(
|
||||
"/tmp/spark-cache/spark-store/issue-bot/state.json",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns a default state when the file does not exist", () => {
|
||||
vi.spyOn(fs, "existsSync").mockReturnValue(false);
|
||||
|
||||
expect(loadIssueBotState()).toEqual(createDefaultIssueBotState());
|
||||
});
|
||||
|
||||
it("backs up invalid JSON and resets to the default state", () => {
|
||||
vi.spyOn(fs, "existsSync").mockReturnValue(true);
|
||||
vi.spyOn(fs, "readFileSync").mockReturnValue("not-json");
|
||||
const renameSync = vi.spyOn(fs, "renameSync").mockImplementation(() => {});
|
||||
|
||||
expect(loadIssueBotState()).toEqual(createDefaultIssueBotState());
|
||||
expect(renameSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining("state.json"),
|
||||
expect.stringContaining("state.json.bak-"),
|
||||
);
|
||||
});
|
||||
|
||||
it("creates parent directories before saving state", () => {
|
||||
const mkdirSync = vi
|
||||
.spyOn(fs, "mkdirSync")
|
||||
.mockImplementation(() => undefined);
|
||||
const writeFileSync = vi
|
||||
.spyOn(fs, "writeFileSync")
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
saveIssueBotState({
|
||||
...createDefaultIssueBotState(),
|
||||
lastRunStatus: "success",
|
||||
lastRunMessage: "candidate updated",
|
||||
});
|
||||
|
||||
expect(mkdirSync).toHaveBeenCalledWith(
|
||||
path.dirname(getIssueBotStatePath()),
|
||||
{ recursive: true },
|
||||
);
|
||||
expect(writeFileSync).toHaveBeenCalledWith(
|
||||
getIssueBotStatePath(),
|
||||
expect.stringContaining('"lastRunStatus": "success"'),
|
||||
"utf8",
|
||||
);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `npm run test -- --run src/__tests__/unit/issue-bot/state.test.ts`
|
||||
|
||||
Expected: FAIL with `Cannot find module '../../../../scripts/issue-bot/lib/state'`.
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
```ts
|
||||
// scripts/issue-bot/lib/types.ts
|
||||
export interface NormalizedIssue {
|
||||
id: number;
|
||||
number: string;
|
||||
title: string;
|
||||
url: string;
|
||||
state: "open" | "closed";
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
labels: string[];
|
||||
bodyPreview: string;
|
||||
}
|
||||
|
||||
export interface RankedIssue extends NormalizedIssue {
|
||||
score: number;
|
||||
rankingReasons: string[];
|
||||
}
|
||||
|
||||
export interface ApprovedIssue {
|
||||
id: number;
|
||||
title: string;
|
||||
url: string;
|
||||
approvedAt: string;
|
||||
}
|
||||
|
||||
export interface IssueBotState {
|
||||
currentCandidate: RankedIssue | null;
|
||||
approvedIssue: ApprovedIssue | null;
|
||||
seenIssueIds: number[];
|
||||
lastRunAt: string | null;
|
||||
lastRunStatus: "idle" | "success" | "network-error" | "parse-error";
|
||||
lastRunMessage: string | null;
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
// scripts/issue-bot/lib/state.ts
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import type { IssueBotState } from "./types";
|
||||
|
||||
export const createDefaultIssueBotState = (): IssueBotState => ({
|
||||
currentCandidate: null,
|
||||
approvedIssue: null,
|
||||
seenIssueIds: [],
|
||||
lastRunAt: null,
|
||||
lastRunStatus: "idle",
|
||||
lastRunMessage: null,
|
||||
});
|
||||
|
||||
export const getIssueBotStatePath = (): string => {
|
||||
const cacheRoot =
|
||||
process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache");
|
||||
return path.join(cacheRoot, "spark-store", "issue-bot", "state.json");
|
||||
};
|
||||
|
||||
export const loadIssueBotState = (): IssueBotState => {
|
||||
const filePath = getIssueBotStatePath();
|
||||
if (!fs.existsSync(filePath)) return createDefaultIssueBotState();
|
||||
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, "utf8");
|
||||
return {
|
||||
...createDefaultIssueBotState(),
|
||||
...(JSON.parse(raw) as Partial<IssueBotState>),
|
||||
};
|
||||
} catch {
|
||||
const backupPath = `${filePath}.bak-${Date.now()}`;
|
||||
fs.renameSync(filePath, backupPath);
|
||||
return createDefaultIssueBotState();
|
||||
}
|
||||
};
|
||||
|
||||
export const saveIssueBotState = (state: IssueBotState): void => {
|
||||
const filePath = getIssueBotStatePath();
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `npm run test -- --run src/__tests__/unit/issue-bot/state.test.ts`
|
||||
|
||||
Expected: PASS with 4 tests passed.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add scripts/issue-bot/lib/types.ts scripts/issue-bot/lib/state.ts src/__tests__/unit/issue-bot/state.test.ts
|
||||
git commit -m "feat(issue-bot): add local state storage"
|
||||
```
|
||||
|
||||
### Task 2: Add Ranking Rules and Candidate Selection
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `scripts/issue-bot/lib/ranking.ts`
|
||||
- Test: `src/__tests__/unit/issue-bot/ranking.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```ts
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
rankIssues,
|
||||
selectTopIssueCandidate,
|
||||
} from "../../../../scripts/issue-bot/lib/ranking";
|
||||
import type { NormalizedIssue } from "../../../../scripts/issue-bot/lib/types";
|
||||
|
||||
const makeIssue = (overrides: Partial<NormalizedIssue>): NormalizedIssue => ({
|
||||
id: 1,
|
||||
number: "I123",
|
||||
title: "示例 issue",
|
||||
url: "https://gitee.com/spark-store-project/spark-store/issues/I123",
|
||||
state: "open",
|
||||
createdAt: "2026-04-14T00:00:00.000Z",
|
||||
updatedAt: "2026-04-14T00:00:00.000Z",
|
||||
labels: [],
|
||||
bodyPreview: "用户反馈应用无法安装,并附上了复现步骤和日志。",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("issue-bot ranking", () => {
|
||||
it("prioritizes install failures with actionable details", () => {
|
||||
const ranked = rankIssues([
|
||||
makeIssue({ id: 1, title: "应用无法安装,附日志" }),
|
||||
makeIssue({ id: 2, title: "建议增加分类筛选", bodyPreview: "功能建议" }),
|
||||
]);
|
||||
|
||||
expect(ranked[0].id).toBe(1);
|
||||
expect(ranked[0].score).toBeGreaterThan(ranked[1].score);
|
||||
expect(ranked[0].rankingReasons).toContain(
|
||||
"contains high-impact keyword: 无法安装",
|
||||
);
|
||||
});
|
||||
|
||||
it("filters out closed issues and already-approved issues", () => {
|
||||
const candidate = selectTopIssueCandidate(
|
||||
[
|
||||
makeIssue({ id: 3, state: "closed", title: "已关闭问题" }),
|
||||
makeIssue({ id: 4, title: "白屏并卡死" }),
|
||||
],
|
||||
{ approvedIssueId: 4 },
|
||||
);
|
||||
|
||||
expect(candidate).toBeNull();
|
||||
});
|
||||
|
||||
it("prefers more recently updated issues when scores otherwise match", () => {
|
||||
const candidate = selectTopIssueCandidate(
|
||||
[
|
||||
makeIssue({
|
||||
id: 5,
|
||||
title: "启动白屏",
|
||||
updatedAt: "2026-04-14T08:00:00.000Z",
|
||||
}),
|
||||
makeIssue({
|
||||
id: 6,
|
||||
title: "启动白屏",
|
||||
updatedAt: "2026-04-14T09:00:00.000Z",
|
||||
}),
|
||||
],
|
||||
{ approvedIssueId: null },
|
||||
);
|
||||
|
||||
expect(candidate?.id).toBe(6);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `npm run test -- --run src/__tests__/unit/issue-bot/ranking.test.ts`
|
||||
|
||||
Expected: FAIL with `Cannot find module '../../../../scripts/issue-bot/lib/ranking'`.
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
```ts
|
||||
// scripts/issue-bot/lib/ranking.ts
|
||||
import type { NormalizedIssue, RankedIssue } from "./types";
|
||||
|
||||
const HIGH_IMPACT_KEYWORDS = [
|
||||
"崩溃",
|
||||
"打不开",
|
||||
"无法安装",
|
||||
"升级失败",
|
||||
"卡死",
|
||||
"白屏",
|
||||
"闪退",
|
||||
];
|
||||
|
||||
const CORE_FLOW_KEYWORDS = ["安装", "卸载", "更新", "启动", "搜索", "加载"];
|
||||
|
||||
const hasActionableDetail = (issue: NormalizedIssue): boolean =>
|
||||
/复现|日志|截图|error|错误/i.test(issue.bodyPreview);
|
||||
|
||||
const scoreIssue = (issue: NormalizedIssue): RankedIssue => {
|
||||
const reasons: string[] = [];
|
||||
let score = 0;
|
||||
const haystack = `${issue.title}\n${issue.bodyPreview}`;
|
||||
|
||||
for (const keyword of HIGH_IMPACT_KEYWORDS) {
|
||||
if (haystack.includes(keyword)) {
|
||||
score += 10;
|
||||
reasons.push(`contains high-impact keyword: ${keyword}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const keyword of CORE_FLOW_KEYWORDS) {
|
||||
if (haystack.includes(keyword)) {
|
||||
score += 4;
|
||||
reasons.push(`touches core flow: ${keyword}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasActionableDetail(issue)) {
|
||||
score += 6;
|
||||
reasons.push("includes actionable detail");
|
||||
}
|
||||
|
||||
if (/建议|需求|希望/.test(haystack)) {
|
||||
score -= 4;
|
||||
reasons.push("looks like feature discussion");
|
||||
}
|
||||
|
||||
return {
|
||||
...issue,
|
||||
score,
|
||||
rankingReasons: reasons,
|
||||
};
|
||||
};
|
||||
|
||||
export const rankIssues = (issues: NormalizedIssue[]): RankedIssue[] =>
|
||||
[...issues]
|
||||
.filter((issue) => issue.state === "open")
|
||||
.map(scoreIssue)
|
||||
.sort((left, right) => {
|
||||
if (right.score !== left.score) return right.score - left.score;
|
||||
return Date.parse(right.updatedAt) - Date.parse(left.updatedAt);
|
||||
});
|
||||
|
||||
export const selectTopIssueCandidate = (
|
||||
issues: NormalizedIssue[],
|
||||
options: { approvedIssueId: number | null },
|
||||
): RankedIssue | null => {
|
||||
const ranked = rankIssues(issues).filter(
|
||||
(issue) => issue.id !== options.approvedIssueId,
|
||||
);
|
||||
return ranked[0] ?? null;
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `npm run test -- --run src/__tests__/unit/issue-bot/ranking.test.ts`
|
||||
|
||||
Expected: PASS with 3 tests passed.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add scripts/issue-bot/lib/ranking.ts src/__tests__/unit/issue-bot/ranking.test.ts
|
||||
git commit -m "feat(issue-bot): rank candidate issues"
|
||||
```
|
||||
|
||||
### Task 3: Add Gitee Fetching and Polling Entrypoint
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `scripts/issue-bot/lib/gitee.ts`
|
||||
- Create: `scripts/issue-bot/check-issues.ts`
|
||||
- Test: `src/__tests__/unit/issue-bot/check-issues.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```ts
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type {
|
||||
IssueBotState,
|
||||
NormalizedIssue,
|
||||
} from "../../../../scripts/issue-bot/lib/types";
|
||||
|
||||
const loadState = vi.fn();
|
||||
const saveState = vi.fn();
|
||||
const listOpenIssues = vi.fn();
|
||||
|
||||
vi.mock("../../../../scripts/issue-bot/lib/state", () => ({
|
||||
createDefaultIssueBotState: () => ({
|
||||
currentCandidate: null,
|
||||
approvedIssue: null,
|
||||
seenIssueIds: [],
|
||||
lastRunAt: null,
|
||||
lastRunStatus: "idle",
|
||||
lastRunMessage: null,
|
||||
}),
|
||||
loadIssueBotState: loadState,
|
||||
saveIssueBotState: saveState,
|
||||
}));
|
||||
|
||||
vi.mock("../../../../scripts/issue-bot/lib/gitee", () => ({
|
||||
listOpenIssues,
|
||||
}));
|
||||
|
||||
describe("check-issues", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
loadState.mockReset();
|
||||
saveState.mockReset();
|
||||
listOpenIssues.mockReset();
|
||||
});
|
||||
|
||||
it("stores the top-ranked issue candidate", async () => {
|
||||
const baseState: IssueBotState = {
|
||||
currentCandidate: null,
|
||||
approvedIssue: null,
|
||||
seenIssueIds: [],
|
||||
lastRunAt: null,
|
||||
lastRunStatus: "idle",
|
||||
lastRunMessage: null,
|
||||
};
|
||||
|
||||
loadState.mockReturnValue(baseState);
|
||||
listOpenIssues.mockResolvedValue([
|
||||
{
|
||||
id: 10,
|
||||
number: "I10",
|
||||
title: "应用无法安装并白屏",
|
||||
url: "https://gitee.com/spark-store-project/spark-store/issues/I10",
|
||||
state: "open",
|
||||
createdAt: "2026-04-14T00:00:00.000Z",
|
||||
updatedAt: "2026-04-14T09:00:00.000Z",
|
||||
labels: ["bug"],
|
||||
bodyPreview: "复现步骤:1. 打开商店 2. 点击安装。附日志。",
|
||||
},
|
||||
] satisfies NormalizedIssue[]);
|
||||
|
||||
const { runIssueBotCheck } =
|
||||
await import("../../../../scripts/issue-bot/check-issues");
|
||||
await runIssueBotCheck();
|
||||
|
||||
expect(saveState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
currentCandidate: expect.objectContaining({
|
||||
id: 10,
|
||||
title: "应用无法安装并白屏",
|
||||
}),
|
||||
lastRunStatus: "success",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the previous candidate when fetching issues fails", async () => {
|
||||
loadState.mockReturnValue({
|
||||
currentCandidate: {
|
||||
id: 99,
|
||||
number: "I99",
|
||||
title: "旧候选",
|
||||
url: "https://gitee.com/spark-store-project/spark-store/issues/I99",
|
||||
state: "open",
|
||||
createdAt: "2026-04-14T00:00:00.000Z",
|
||||
updatedAt: "2026-04-14T00:00:00.000Z",
|
||||
labels: [],
|
||||
bodyPreview: "旧摘要",
|
||||
score: 12,
|
||||
rankingReasons: ["legacy candidate"],
|
||||
},
|
||||
approvedIssue: null,
|
||||
seenIssueIds: [],
|
||||
lastRunAt: null,
|
||||
lastRunStatus: "idle",
|
||||
lastRunMessage: null,
|
||||
});
|
||||
listOpenIssues.mockRejectedValue(new Error("network down"));
|
||||
|
||||
const { runIssueBotCheck } =
|
||||
await import("../../../../scripts/issue-bot/check-issues");
|
||||
await runIssueBotCheck();
|
||||
|
||||
expect(saveState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
currentCandidate: expect.objectContaining({ id: 99 }),
|
||||
lastRunStatus: "network-error",
|
||||
lastRunMessage: "network down",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `npm run test -- --run src/__tests__/unit/issue-bot/check-issues.test.ts`
|
||||
|
||||
Expected: FAIL with `Cannot find module '../../../../scripts/issue-bot/check-issues'`.
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
```ts
|
||||
// scripts/issue-bot/lib/gitee.ts
|
||||
import type { NormalizedIssue } from "./types";
|
||||
|
||||
interface GiteeIssueApiResponse {
|
||||
id: number;
|
||||
number: string;
|
||||
title: string;
|
||||
state: "open" | "closed";
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
body?: string;
|
||||
html_url: string;
|
||||
labels?: Array<{ name?: string }>;
|
||||
}
|
||||
|
||||
const GITEE_ISSUES_API_URL =
|
||||
"https://gitee.com/api/v5/repos/spark-store-project/spark-store/issues?state=open&sort=updated&direction=desc&page=1&per_page=50";
|
||||
|
||||
export const listOpenIssues = async (): Promise<NormalizedIssue[]> => {
|
||||
const response = await fetch(GITEE_ISSUES_API_URL);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Gitee request failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as GiteeIssueApiResponse[];
|
||||
return payload.map((issue) => ({
|
||||
id: issue.id,
|
||||
number: issue.number,
|
||||
title: issue.title,
|
||||
url: issue.html_url,
|
||||
state: issue.state,
|
||||
createdAt: issue.created_at,
|
||||
updatedAt: issue.updated_at,
|
||||
labels: (issue.labels || [])
|
||||
.map((label) => label.name?.trim() || "")
|
||||
.filter((label) => label.length > 0),
|
||||
bodyPreview: (issue.body || "").slice(0, 500),
|
||||
}));
|
||||
};
|
||||
```
|
||||
|
||||
```ts
|
||||
// scripts/issue-bot/check-issues.ts
|
||||
import { listOpenIssues } from "./lib/gitee";
|
||||
import { selectTopIssueCandidate } from "./lib/ranking";
|
||||
import { loadIssueBotState, saveIssueBotState } from "./lib/state";
|
||||
|
||||
export const runIssueBotCheck = async (): Promise<void> => {
|
||||
const state = loadIssueBotState();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
try {
|
||||
const issues = await listOpenIssues();
|
||||
const candidate = selectTopIssueCandidate(issues, {
|
||||
approvedIssueId: state.approvedIssue?.id ?? null,
|
||||
});
|
||||
|
||||
saveIssueBotState({
|
||||
...state,
|
||||
currentCandidate: candidate,
|
||||
seenIssueIds: candidate
|
||||
? Array.from(new Set([...state.seenIssueIds, candidate.id]))
|
||||
: state.seenIssueIds,
|
||||
lastRunAt: now,
|
||||
lastRunStatus: "success",
|
||||
lastRunMessage: candidate
|
||||
? `candidate updated: ${candidate.title}`
|
||||
: "no candidate issues found",
|
||||
});
|
||||
} catch (error) {
|
||||
saveIssueBotState({
|
||||
...state,
|
||||
lastRunAt: now,
|
||||
lastRunStatus: "network-error",
|
||||
lastRunMessage: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
runIssueBotCheck().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `npm run test -- --run src/__tests__/unit/issue-bot/check-issues.test.ts`
|
||||
|
||||
Expected: PASS with 2 tests passed.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add scripts/issue-bot/lib/gitee.ts scripts/issue-bot/check-issues.ts src/__tests__/unit/issue-bot/check-issues.test.ts
|
||||
git commit -m "feat(issue-bot): poll gitee issues"
|
||||
```
|
||||
|
||||
### Task 4: Add Opencode Prompt Generation and Manual Approval
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `scripts/issue-bot/lib/opencode.ts`
|
||||
- Create: `scripts/issue-bot/approve-issue.ts`
|
||||
- Test: `src/__tests__/unit/issue-bot/approve-issue.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```ts
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const loadState = vi.fn();
|
||||
const saveState = vi.fn();
|
||||
const launchOpencodeForIssue = vi.fn();
|
||||
|
||||
vi.mock("../../../../scripts/issue-bot/lib/state", () => ({
|
||||
loadIssueBotState: loadState,
|
||||
saveIssueBotState: saveState,
|
||||
}));
|
||||
|
||||
vi.mock("../../../../scripts/issue-bot/lib/opencode", () => ({
|
||||
launchOpencodeForIssue,
|
||||
}));
|
||||
|
||||
describe("approve-issue", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
loadState.mockReset();
|
||||
saveState.mockReset();
|
||||
launchOpencodeForIssue.mockReset();
|
||||
});
|
||||
|
||||
it("marks the current candidate as approved and launches opencode", async () => {
|
||||
loadState.mockReturnValue({
|
||||
currentCandidate: {
|
||||
id: 42,
|
||||
number: "I42",
|
||||
title: "应用升级失败并白屏",
|
||||
url: "https://gitee.com/spark-store-project/spark-store/issues/I42",
|
||||
state: "open",
|
||||
createdAt: "2026-04-14T00:00:00.000Z",
|
||||
updatedAt: "2026-04-14T00:00:00.000Z",
|
||||
labels: ["bug"],
|
||||
bodyPreview: "更新后白屏,附日志。",
|
||||
score: 20,
|
||||
rankingReasons: ["contains high-impact keyword: 升级失败"],
|
||||
},
|
||||
approvedIssue: null,
|
||||
seenIssueIds: [42],
|
||||
lastRunAt: "2026-04-14T09:00:00.000Z",
|
||||
lastRunStatus: "success",
|
||||
lastRunMessage: "candidate updated",
|
||||
});
|
||||
|
||||
const { runIssueBotApproval } =
|
||||
await import("../../../../scripts/issue-bot/approve-issue");
|
||||
await runIssueBotApproval();
|
||||
|
||||
expect(launchOpencodeForIssue).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 42, title: "应用升级失败并白屏" }),
|
||||
);
|
||||
expect(saveState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
currentCandidate: null,
|
||||
approvedIssue: expect.objectContaining({ id: 42 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when there is no candidate to approve", async () => {
|
||||
loadState.mockReturnValue({
|
||||
currentCandidate: null,
|
||||
approvedIssue: null,
|
||||
seenIssueIds: [],
|
||||
lastRunAt: null,
|
||||
lastRunStatus: "idle",
|
||||
lastRunMessage: null,
|
||||
});
|
||||
|
||||
const { runIssueBotApproval } =
|
||||
await import("../../../../scripts/issue-bot/approve-issue");
|
||||
|
||||
await expect(runIssueBotApproval()).rejects.toThrow(
|
||||
"No current issue candidate to approve.",
|
||||
);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `npm run test -- --run src/__tests__/unit/issue-bot/approve-issue.test.ts`
|
||||
|
||||
Expected: FAIL with `Cannot find module '../../../../scripts/issue-bot/approve-issue'`.
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
```ts
|
||||
// scripts/issue-bot/lib/opencode.ts
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
import type { RankedIssue } from "./types";
|
||||
|
||||
export const buildOpencodePrompt = (
|
||||
issue: RankedIssue,
|
||||
): string => `请处理以下 Spark Store issue:
|
||||
|
||||
标题:${issue.title}
|
||||
链接:${issue.url}
|
||||
摘要:${issue.bodyPreview}
|
||||
优先级原因:${issue.rankingReasons.join(";")}
|
||||
|
||||
要求:先分析根因,再开始修复。默认基仓库必须使用 ~/Desktop/spark-store。
|
||||
如果开始修改代码,必须先使用 git worktree,从 Erotica 分支开出新的工作分支,并在该 worktree 中实施改动,不要直接在主工作区修改。`;
|
||||
|
||||
export const launchOpencodeForIssue = async (
|
||||
issue: RankedIssue,
|
||||
): Promise<void> => {
|
||||
const configuredCommand = process.env.SPARK_STORE_OPENCODE_CMD || "opencode";
|
||||
const child = spawn(configuredCommand, [buildOpencodePrompt(issue)], {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
shell: true,
|
||||
});
|
||||
|
||||
child.unref();
|
||||
};
|
||||
```
|
||||
|
||||
```ts
|
||||
// scripts/issue-bot/approve-issue.ts
|
||||
import { launchOpencodeForIssue } from "./lib/opencode";
|
||||
import { loadIssueBotState, saveIssueBotState } from "./lib/state";
|
||||
|
||||
export const runIssueBotApproval = async (): Promise<void> => {
|
||||
const state = loadIssueBotState();
|
||||
const candidate = state.currentCandidate;
|
||||
|
||||
if (!candidate) {
|
||||
throw new Error("No current issue candidate to approve.");
|
||||
}
|
||||
|
||||
await launchOpencodeForIssue(candidate);
|
||||
|
||||
saveIssueBotState({
|
||||
...state,
|
||||
currentCandidate: null,
|
||||
approvedIssue: {
|
||||
id: candidate.id,
|
||||
title: candidate.title,
|
||||
url: candidate.url,
|
||||
approvedAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
runIssueBotApproval().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `npm run test -- --run src/__tests__/unit/issue-bot/approve-issue.test.ts`
|
||||
|
||||
Expected: PASS with 2 tests passed.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add scripts/issue-bot/lib/opencode.ts scripts/issue-bot/approve-issue.ts src/__tests__/unit/issue-bot/approve-issue.test.ts
|
||||
git commit -m "feat(issue-bot): approve candidates and launch opencode"
|
||||
```
|
||||
|
||||
### Task 5: Wire npm Scripts and systemd Units
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `package.json`
|
||||
- Modify: `tsconfig.node.json`
|
||||
- Create: `extras/systemd/spark-store-issue-bot.service`
|
||||
- Create: `extras/systemd/spark-store-issue-bot.timer`
|
||||
- Create: `src/__tests__/unit/issue-bot/packaging.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```ts
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import pkg from "../../../../package.json";
|
||||
import serviceUnit from "../../../../extras/systemd/spark-store-issue-bot.service?raw";
|
||||
import timerUnit from "../../../../extras/systemd/spark-store-issue-bot.timer?raw";
|
||||
|
||||
describe("issue-bot packaging", () => {
|
||||
it("adds npm scripts for polling and approval", () => {
|
||||
expect(pkg.scripts["issue-bot:check"]).toBe(
|
||||
"node --experimental-strip-types scripts/issue-bot/check-issues.ts",
|
||||
);
|
||||
expect(pkg.scripts["issue-bot:approve"]).toBe(
|
||||
"node --experimental-strip-types scripts/issue-bot/approve-issue.ts",
|
||||
);
|
||||
});
|
||||
|
||||
it("installs a six-hour persistent user timer", () => {
|
||||
expect(serviceUnit).toContain("Type=oneshot");
|
||||
expect(serviceUnit).toContain(
|
||||
"ExecStart=/usr/bin/env npm run issue-bot:check",
|
||||
);
|
||||
expect(timerUnit).toContain("OnUnitActiveSec=6h");
|
||||
expect(timerUnit).toContain("Persistent=true");
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `npm run test -- --run src/__tests__/unit/issue-bot/packaging.test.ts`
|
||||
|
||||
Expected: FAIL with `Failed to resolve import '../../../../extras/systemd/spark-store-issue-bot.service?raw'` and missing package scripts.
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
```json
|
||||
// package.json
|
||||
{
|
||||
"scripts": {
|
||||
"issue-bot:check": "node --experimental-strip-types scripts/issue-bot/check-issues.ts",
|
||||
"issue-bot:approve": "node --experimental-strip-types scripts/issue-bot/approve-issue.ts"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
// tsconfig.node.json
|
||||
{
|
||||
"include": ["vite.config.ts", "package.json", "electron", "scripts"]
|
||||
}
|
||||
```
|
||||
|
||||
```ini
|
||||
; extras/systemd/spark-store-issue-bot.service
|
||||
[Unit]
|
||||
Description=Spark Store issue bot poller
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
WorkingDirectory=%h/Desktop/spark-store
|
||||
ExecStart=/usr/bin/env npm run issue-bot:check
|
||||
```
|
||||
|
||||
```ini
|
||||
; extras/systemd/spark-store-issue-bot.timer
|
||||
[Unit]
|
||||
Description=Run Spark Store issue bot every 6 hours
|
||||
|
||||
[Timer]
|
||||
OnBootSec=15m
|
||||
OnUnitActiveSec=6h
|
||||
Persistent=true
|
||||
Unit=spark-store-issue-bot.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `npm run test -- --run src/__tests__/unit/issue-bot/packaging.test.ts`
|
||||
|
||||
Expected: PASS with 2 tests passed.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add package.json tsconfig.node.json extras/systemd/spark-store-issue-bot.service extras/systemd/spark-store-issue-bot.timer src/__tests__/unit/issue-bot/packaging.test.ts
|
||||
git commit -m "chore(issue-bot): wire scripts and timer units"
|
||||
```
|
||||
|
||||
### Task 6: Run End-to-End Verification
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `scripts/issue-bot/check-issues.ts`
|
||||
- Modify: `scripts/issue-bot/approve-issue.ts`
|
||||
- Modify: `scripts/issue-bot/lib/gitee.ts`
|
||||
- Modify: `scripts/issue-bot/lib/opencode.ts`
|
||||
- Modify: `scripts/issue-bot/lib/ranking.ts`
|
||||
- Modify: `scripts/issue-bot/lib/state.ts`
|
||||
- Modify: `package.json`
|
||||
- Modify: `tsconfig.node.json`
|
||||
- Create: `extras/systemd/spark-store-issue-bot.service`
|
||||
- Create: `extras/systemd/spark-store-issue-bot.timer`
|
||||
- Test: `src/__tests__/unit/issue-bot/state.test.ts`
|
||||
- Test: `src/__tests__/unit/issue-bot/ranking.test.ts`
|
||||
- Test: `src/__tests__/unit/issue-bot/check-issues.test.ts`
|
||||
- Test: `src/__tests__/unit/issue-bot/approve-issue.test.ts`
|
||||
- Test: `src/__tests__/unit/issue-bot/packaging.test.ts`
|
||||
|
||||
- [ ] **Step 1: Run focused issue-bot tests**
|
||||
|
||||
Run: `npm run test -- --run src/__tests__/unit/issue-bot/state.test.ts src/__tests__/unit/issue-bot/ranking.test.ts src/__tests__/unit/issue-bot/check-issues.test.ts src/__tests__/unit/issue-bot/approve-issue.test.ts src/__tests__/unit/issue-bot/packaging.test.ts`
|
||||
|
||||
Expected: PASS with all issue-bot tests green.
|
||||
|
||||
- [ ] **Step 2: Run lint**
|
||||
|
||||
Run: `npm run lint`
|
||||
|
||||
Expected: PASS with no ESLint errors in `scripts/issue-bot`, `src/__tests__/unit/issue-bot`, and touched config files.
|
||||
|
||||
- [ ] **Step 3: Run build verification**
|
||||
|
||||
Run: `npm run build:vite`
|
||||
|
||||
Expected: PASS with Electron/Vite bundles generated and no TypeScript errors after adding `scripts` to `tsconfig.node.json`.
|
||||
|
||||
- [ ] **Step 4: Manually verify CLI entrypoints**
|
||||
|
||||
Run: `npm run issue-bot:check`
|
||||
|
||||
Expected: `~/.cache/spark-store/issue-bot/state.json` exists and contains either a populated `currentCandidate` or a `lastRunMessage` of `no candidate issues found`.
|
||||
|
||||
Run: `SPARK_STORE_OPENCODE_CMD='printf' npm run issue-bot:approve`
|
||||
|
||||
Expected: command exits successfully and prints the generated prompt containing both `~/Desktop/spark-store` and `Erotica`.
|
||||
|
||||
- [ ] **Step 5: Manually verify systemd units**
|
||||
|
||||
Run: `systemctl --user start spark-store-issue-bot.service`
|
||||
|
||||
Expected: service runs once without unit-file syntax errors.
|
||||
|
||||
Run: `systemctl --user enable --now spark-store-issue-bot.timer`
|
||||
|
||||
Expected: timer is enabled, active, and reports the next run roughly 6 hours later.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add scripts/issue-bot package.json tsconfig.node.json extras/systemd/spark-store-issue-bot.service extras/systemd/spark-store-issue-bot.timer src/__tests__/unit/issue-bot
|
||||
git commit -m "feat(issue-bot): add automated issue polling workflow"
|
||||
```
|
||||
|
||||
## Self-Review
|
||||
|
||||
### Spec coverage
|
||||
|
||||
- `systemd --user` timer requirement: covered by Task 5 and Task 6.
|
||||
- One-candidate ranking with explainable reasons: covered by Task 2 and Task 3.
|
||||
- Manual approval before opencode launch: covered by Task 4.
|
||||
- Local cache-backed state with failure retention: covered by Task 1 and Task 3.
|
||||
- `~/Desktop/spark-store` + `Erotica` worktree rule in the launch prompt: covered by Task 4 and manual verification in Task 6.
|
||||
|
||||
### Placeholder scan
|
||||
|
||||
- No `TBD`, `TODO`, or “implement later” placeholders remain.
|
||||
- All code-changing steps include concrete code blocks.
|
||||
- All verification steps include exact commands and expected outcomes.
|
||||
|
||||
### Type consistency
|
||||
|
||||
- `NormalizedIssue`, `RankedIssue`, `ApprovedIssue`, and `IssueBotState` are defined in Task 1 and reused consistently in Tasks 2-4.
|
||||
- `runIssueBotCheck`, `runIssueBotApproval`, and `launchOpencodeForIssue` names stay unchanged across tests and implementation steps.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,152 @@
|
||||
# Installed Apps Modal Actions Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Restore launch and detail entry points from the installed-apps modal by wiring explicit `打开` and `查看详情` actions back to the existing parent handlers.
|
||||
|
||||
**Architecture:** Keep the fix local to the installed-apps modal and `App.vue`. Add two emitted events from `InstalledAppsModal.vue`, conditionally render the detail action when the app has usable store metadata, and connect those events to the existing `openDownloadedApp()` and `openDetail()` logic in the parent.
|
||||
|
||||
**Tech Stack:** Vue 3, TypeScript, Vitest, Testing Library Vue
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- Modify: `src/components/InstalledAppsModal.vue`
|
||||
Responsibility: render open/detail actions for installed app rows and emit events upward.
|
||||
- Modify: `src/App.vue`
|
||||
Responsibility: wire modal events to existing launch/detail handlers.
|
||||
- Modify: `src/__tests__/unit/InstalledAppsModal.test.ts`
|
||||
Responsibility: prove action buttons render and emit correctly.
|
||||
|
||||
### Task 1: Add Failing Modal Tests
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/__tests__/unit/InstalledAppsModal.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing tests for open/detail actions**
|
||||
|
||||
```ts
|
||||
it("renders open and detail actions for a store-backed installed app", async () => {
|
||||
// render with one installed app whose category is not unknown
|
||||
// expect 打开 and 查看详情 buttons to exist
|
||||
});
|
||||
|
||||
it("emits open-app when clicking 打开", async () => {
|
||||
// click open button
|
||||
// expect emitted()['open-app']
|
||||
});
|
||||
|
||||
it("emits open-detail when clicking 查看详情", async () => {
|
||||
// click detail button
|
||||
// expect emitted()['open-detail']
|
||||
});
|
||||
|
||||
it("hides 查看详情 for unknown-category apps", () => {
|
||||
// render app with category unknown
|
||||
// expect no 查看详情 button
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `npx vitest run src/__tests__/unit/InstalledAppsModal.test.ts`
|
||||
Expected: FAIL because the modal does not yet render or emit the new actions
|
||||
|
||||
### Task 2: Implement Modal Actions
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/components/InstalledAppsModal.vue`
|
||||
- Modify: `src/App.vue`
|
||||
|
||||
- [ ] **Step 1: Add minimal modal action rendering and emits**
|
||||
|
||||
```ts
|
||||
defineEmits<{
|
||||
(e: "close"): void;
|
||||
(e: "refresh"): void;
|
||||
(e: "uninstall", app: App): void;
|
||||
(e: "switch-origin", origin: "apm" | "spark"): void;
|
||||
(e: "open-app", app: App): void;
|
||||
(e: "open-detail", app: App): void;
|
||||
}>();
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add a simple detail-eligibility helper**
|
||||
|
||||
```ts
|
||||
const canOpenDetail = (app: App) => {
|
||||
return (
|
||||
app.category !== "unknown" ||
|
||||
Boolean(app.more) ||
|
||||
Boolean(app.website) ||
|
||||
Boolean(app.author) ||
|
||||
(app.img_urls?.length ?? 0) > 0
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add 打开 / 查看详情 buttons to each row**
|
||||
|
||||
```vue
|
||||
<button type="button" @click="$emit('open-app', app)">打开</button>
|
||||
<button
|
||||
v-if="canOpenDetail(app)"
|
||||
type="button"
|
||||
@click="$emit('open-detail', app)"
|
||||
>
|
||||
查看详情
|
||||
</button>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Wire parent events to existing handlers**
|
||||
|
||||
```vue
|
||||
<InstalledAppsModal
|
||||
...
|
||||
@open-app="openDownloadedApp($event.pkgname, $event.origin)"
|
||||
@open-detail="openDetail"
|
||||
/>
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run test to verify it passes**
|
||||
|
||||
Run: `npx vitest run src/__tests__/unit/InstalledAppsModal.test.ts`
|
||||
Expected: PASS
|
||||
|
||||
### Task 3: Verification And Commit
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/components/InstalledAppsModal.vue`
|
||||
- Modify: `src/App.vue`
|
||||
- Modify: `src/__tests__/unit/InstalledAppsModal.test.ts`
|
||||
|
||||
- [ ] **Step 1: Run focused modal test**
|
||||
|
||||
Run: `npx vitest run src/__tests__/unit/InstalledAppsModal.test.ts`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 2: Run repository lint**
|
||||
|
||||
Run: `npm run lint`
|
||||
Expected: exit 0
|
||||
|
||||
- [ ] **Step 3: Run repository build**
|
||||
|
||||
Run: `npm run build:vite`
|
||||
Expected: exit 0
|
||||
|
||||
- [ ] **Step 4: Review final diff**
|
||||
|
||||
Run: `git diff -- src/components/InstalledAppsModal.vue src/App.vue src/__tests__/unit/InstalledAppsModal.test.ts docs/superpowers/specs/2026-04-15-installed-apps-modal-actions-design.md docs/superpowers/plans/2026-04-15-installed-apps-modal-actions.md`
|
||||
Expected: only installed-app actions and docs changes appear
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/components/InstalledAppsModal.vue src/App.vue src/__tests__/unit/InstalledAppsModal.test.ts docs/superpowers/specs/2026-04-15-installed-apps-modal-actions-design.md docs/superpowers/plans/2026-04-15-installed-apps-modal-actions.md
|
||||
git commit -m "fix(installed-apps): restore open and detail actions"
|
||||
```
|
||||
@@ -0,0 +1,105 @@
|
||||
# Update Ignore Configuration Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Move update-ignore persistence to user config, add ignore and unignore controls to the Electron update center, and make the legacy Qt updater plus root notifier honor the same `pkg|newVersion` rules.
|
||||
|
||||
**Architecture:** Keep the existing text config format and IPC channels. Change the default config path in the Electron backend, expose ignore actions in the renderer store and item component, align the Qt updater with the same new-version key semantics, and teach the notifier to discover user config files without trusting root `HOME`.
|
||||
|
||||
**Tech Stack:** TypeScript, Vue 3, Electron IPC, Vitest, Qt/C++, POSIX shell
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- Modify: `electron/main/backend/update-center/ignore-config.ts`
|
||||
Responsibility: switch the default ignore config path to the user config directory and keep exact `pkg|version` matching.
|
||||
- Modify: `electron/main/backend/update-center/service.ts`
|
||||
Responsibility: apply ignored sorting after refresh.
|
||||
- Modify: `src/modules/updateCenter.ts`
|
||||
Responsibility: expose ignore and unignore actions to the renderer.
|
||||
- Modify: `src/components/update-center/UpdateCenterItem.vue`
|
||||
Responsibility: render ignore and unignore controls for each item.
|
||||
- Modify: `src/components/update-center/UpdateCenterList.vue`
|
||||
Responsibility: bubble ignore and unignore item events upward.
|
||||
- Modify: `src/components/UpdateCenterModal.vue`
|
||||
Responsibility: wire ignore and unignore item events to the store.
|
||||
- Modify: `src/__tests__/unit/update-center/ignore-config.test.ts`
|
||||
Responsibility: prove the new default path resolves to the user config directory.
|
||||
- Modify: `src/__tests__/unit/update-center/store.test.ts`
|
||||
Responsibility: prove ignore and unignore call the preload bridge and refresh state.
|
||||
- Modify: `src/__tests__/unit/update-center/UpdateCenterModal.test.ts`
|
||||
Responsibility: prove ignore-state actions render correctly.
|
||||
- Modify: `spark-update-tool/src/ignoreconfig.cpp`
|
||||
Responsibility: move the Qt config path to the user config directory.
|
||||
- Modify: `spark-update-tool/src/ignoreconfig.h`
|
||||
Responsibility: support exact unignore by package plus version.
|
||||
- Modify: `spark-update-tool/src/appdelegate.cpp`
|
||||
Responsibility: emit the target new version when ignoring or unignoring.
|
||||
- Modify: `spark-update-tool/src/appdelegate.h`
|
||||
Responsibility: update the unignore signal signature.
|
||||
- Modify: `spark-update-tool/src/mainwindow.cpp`
|
||||
Responsibility: match ignored state against new versions and remove exact entries.
|
||||
- Modify: `spark-update-tool/src/mainwindow.h`
|
||||
Responsibility: update slot signatures.
|
||||
- Modify: `tool/update-upgrade/ss-update-notifier.sh`
|
||||
Responsibility: locate user config files from a root service context and filter exact `pkg|newVersion` matches.
|
||||
|
||||
## Task 1: Electron Ignore Path And Renderer Actions
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `electron/main/backend/update-center/ignore-config.ts`
|
||||
- Modify: `electron/main/backend/update-center/service.ts`
|
||||
- Modify: `src/modules/updateCenter.ts`
|
||||
- Modify: `src/components/update-center/UpdateCenterItem.vue`
|
||||
- Modify: `src/components/update-center/UpdateCenterList.vue`
|
||||
- Modify: `src/components/UpdateCenterModal.vue`
|
||||
- Modify: `src/__tests__/unit/update-center/ignore-config.test.ts`
|
||||
- Modify: `src/__tests__/unit/update-center/store.test.ts`
|
||||
- Modify: `src/__tests__/unit/update-center/UpdateCenterModal.test.ts`
|
||||
|
||||
- [ ] Write failing tests for the new user config path, ignore/unignore store methods, and item actions.
|
||||
- [ ] Run `npx vitest run src/__tests__/unit/update-center/ignore-config.test.ts src/__tests__/unit/update-center/store.test.ts src/__tests__/unit/update-center/UpdateCenterModal.test.ts` and confirm they fail for the expected reasons.
|
||||
- [ ] Implement the minimal backend path change, item sorting, renderer store methods, and modal wiring.
|
||||
- [ ] Re-run the same Vitest command and confirm it passes.
|
||||
|
||||
## Task 2: Legacy Qt Updater Alignment
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `spark-update-tool/src/ignoreconfig.cpp`
|
||||
- Modify: `spark-update-tool/src/ignoreconfig.h`
|
||||
- Modify: `spark-update-tool/src/appdelegate.cpp`
|
||||
- Modify: `spark-update-tool/src/appdelegate.h`
|
||||
- Modify: `spark-update-tool/src/mainwindow.cpp`
|
||||
- Modify: `spark-update-tool/src/mainwindow.h`
|
||||
|
||||
- [ ] Change Qt config path resolution to `QStandardPaths::ConfigLocation/spark-store/ignored_apps.conf`.
|
||||
- [ ] Switch ignore and unignore to use `packageName + newVersion` exact entries.
|
||||
- [ ] Build-check the Qt target if a local build command is available.
|
||||
|
||||
## Task 3: Root Notifier User Config Discovery
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `tool/update-upgrade/ss-update-notifier.sh`
|
||||
|
||||
- [ ] Add shell helpers to detect a desktop user home when possible.
|
||||
- [ ] Add fallback scanning across `/home/*/.config/spark-store/ignored_apps.conf`.
|
||||
- [ ] Merge all discovered config files into one ignore set.
|
||||
- [ ] Filter updates by exact `pkg|newVersion` instead of package-only.
|
||||
- [ ] Run `bash -n tool/update-upgrade/ss-update-notifier.sh` and confirm syntax is valid.
|
||||
|
||||
## Task 4: Verification And Commit
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: tracked files from Tasks 1-3
|
||||
|
||||
- [ ] Run `npx vitest run src/__tests__/unit/update-center/ignore-config.test.ts src/__tests__/unit/update-center/store.test.ts src/__tests__/unit/update-center/UpdateCenterModal.test.ts`.
|
||||
- [ ] Run `npm run lint`.
|
||||
- [ ] Run `npm run build`.
|
||||
- [ ] Run `bash -n tool/update-upgrade/ss-update-notifier.sh`.
|
||||
- [ ] Review the final diff.
|
||||
- [ ] Create a commit with a message in repository style.
|
||||
@@ -0,0 +1,157 @@
|
||||
# Update Notifier APM Aggregation Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Extend `tool/update-upgrade/ss-update-notifier.sh` so one notifier aggregates effective Spark and APM updates, honoring `hold` status and shared ignored entries while skipping the Spark branch when `aptss` is unavailable.
|
||||
|
||||
**Architecture:** Keep the current notifier script as the single entrypoint and add a second APM counting branch beside the existing Spark branch. Reuse the existing ignored-entry loading logic, count Spark and APM updates independently after source-specific filtering, then combine the remaining counts into one notification.
|
||||
|
||||
**Tech Stack:** Bash, aptss, apm, amber-pm-debug, dpkg-query
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- Modify: `tool/update-upgrade/ss-update-notifier.sh`
|
||||
Responsibility: add APM update parsing and counting, guard Spark execution behind `aptss` availability, reuse ignored-entry filtering for both branches, and keep one aggregated notification path.
|
||||
|
||||
### Task 1: Add Source-Specific Counting Helpers
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `tool/update-upgrade/ss-update-notifier.sh`
|
||||
|
||||
- [ ] **Step 1: Write the failing shell behavior expectation as comments in the plan**
|
||||
|
||||
```bash
|
||||
# Expected behavior after implementation:
|
||||
# 1. If aptss is missing, the script does not call aptss update/ssupdate.
|
||||
# 2. If apm reports upgradable apps, ignored pkg|newVersion entries suppress them.
|
||||
# 3. Spark and APM effective counts are added into one final count.
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run syntax check before changes**
|
||||
|
||||
Run: `bash -n tool/update-upgrade/ss-update-notifier.sh`
|
||||
Expected: exit 0
|
||||
|
||||
- [ ] **Step 3: Add minimal helper functions for APM parsing and per-source counting**
|
||||
|
||||
```bash
|
||||
function has-command() {
|
||||
command -v "$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
function get_apm_upgradable_list() {
|
||||
local output
|
||||
output=$(env LANGUAGE=en_US apm list --upgradable 2>/dev/null | awk 'NR>1')
|
||||
local ifs_old="$IFS"
|
||||
IFS=$'\n'
|
||||
for line in $output; do
|
||||
local pkg_name
|
||||
local pkg_new_ver
|
||||
local pkg_cur_ver
|
||||
pkg_name=$(echo "$line" | awk -F '/' '{print $1}')
|
||||
pkg_new_ver=$(echo "$line" | awk '{print $2}')
|
||||
pkg_cur_ver=$(printf '%s\n' "$line" | sed -n 's/.*\[\(upgradable from\|from\):[[:space:]]*\([^]]*\)\].*/\2/p')
|
||||
if [ -n "$pkg_name" ] && [ -n "$pkg_new_ver" ] && [ -n "$pkg_cur_ver" ]; then
|
||||
echo "$pkg_name $pkg_new_ver $pkg_cur_ver"
|
||||
fi
|
||||
done
|
||||
IFS="$ifs_old"
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Re-run syntax check after helper changes**
|
||||
|
||||
Run: `bash -n tool/update-upgrade/ss-update-notifier.sh`
|
||||
Expected: exit 0
|
||||
|
||||
### Task 2: Aggregate Spark And APM Effective Counts
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `tool/update-upgrade/ss-update-notifier.sh`
|
||||
|
||||
- [ ] **Step 1: Guard Spark refresh and counting behind aptss availability**
|
||||
|
||||
```bash
|
||||
spark_update_count=0
|
||||
if has-command aptss; then
|
||||
# existing aptss update / aptss ssupdate logic
|
||||
# existing spark upgradable counting logic
|
||||
fi
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add APM refresh and counting branch with hold + ignored filtering**
|
||||
|
||||
```bash
|
||||
apm_update_count=0
|
||||
if has-command apm; then
|
||||
updatetext=$(LANGUAGE=en_US apm update 2>&1)
|
||||
# retry loop matching current script style
|
||||
apm clean
|
||||
PKG_LIST="$(get_apm_upgradable_list)"
|
||||
apm_update_count=$(printf '%s\n' "$PKG_LIST" | awk 'NF { count++ } END { print count + 0 }')
|
||||
# for each package:
|
||||
# - skip if new <= current
|
||||
# - skip if amber-pm-debug dpkg-query says hold
|
||||
# - skip if ignored_apps["$PKG_NAME|$PKG_NEW_VER"] exists
|
||||
# - otherwise increment apm_update_count
|
||||
fi
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Replace single-source final count with aggregated count**
|
||||
|
||||
```bash
|
||||
update_app_number=$((spark_update_count + apm_update_count))
|
||||
if [ "$update_app_number" -le 0 ]; then
|
||||
exit 0
|
||||
fi
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Keep one final notification path**
|
||||
|
||||
```bash
|
||||
notify-send -a spark-store \
|
||||
"${TRANSHELL_CONTENT_SPARK_STORE_UPGRADE_NOTIFY}" \
|
||||
"${TRANSHELL_CONTENT_THERE_ARE_APPS_TO_UPGRADE}" || true
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Re-run syntax check after aggregation changes**
|
||||
|
||||
Run: `bash -n tool/update-upgrade/ss-update-notifier.sh`
|
||||
Expected: exit 0
|
||||
|
||||
### Task 3: Verification And Commit
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `tool/update-upgrade/ss-update-notifier.sh`
|
||||
|
||||
- [ ] **Step 1: Run notifier syntax verification**
|
||||
|
||||
Run: `bash -n tool/update-upgrade/ss-update-notifier.sh`
|
||||
Expected: exit 0
|
||||
|
||||
- [ ] **Step 2: Run repository lint**
|
||||
|
||||
Run: `npm run lint`
|
||||
Expected: exit 0
|
||||
|
||||
- [ ] **Step 3: Run repository build**
|
||||
|
||||
Run: `npm run build:vite`
|
||||
Expected: exit 0
|
||||
|
||||
- [ ] **Step 4: Review final diff**
|
||||
|
||||
Run: `git diff -- tool/update-upgrade/ss-update-notifier.sh docs/superpowers/specs/2026-04-15-update-notifier-apm-aggregation-design.md docs/superpowers/plans/2026-04-15-update-notifier-apm-aggregation.md`
|
||||
Expected: only notifier aggregation and spec/plan changes appear
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add tool/update-upgrade/ss-update-notifier.sh docs/superpowers/specs/2026-04-15-update-notifier-apm-aggregation-design.md docs/superpowers/plans/2026-04-15-update-notifier-apm-aggregation.md
|
||||
git commit -m "fix(update): 聚合 Spark 和 APM 升级通知"
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,150 @@
|
||||
# App Detail Fixed Sidebar Scroll Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Keep the app detail modal's left action/meta column fixed on desktop while the right detail/review column scrolls independently.
|
||||
|
||||
**Architecture:** Reuse the existing `AppDetailModal.vue` popup and change only its internal layout classes/markers. The modal panel becomes a bounded, non-scrolling shell on desktop, while the right column becomes the desktop scroll container; mobile keeps the single-column scroll behavior.
|
||||
|
||||
**Tech Stack:** Vue 3 SFC, Tailwind CSS utilities, Vitest, Testing Library Vue.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- Modify: `src/components/AppDetailModal.vue` - adjust modal shell, left column, right scroll column, and scroll reset target.
|
||||
- Modify: `src/__tests__/unit/AppDetailModal.test.ts` - assert modal shell and scroll column contract.
|
||||
|
||||
## Task 1: Add Layout Contract Test
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/__tests__/unit/AppDetailModal.test.ts`
|
||||
|
||||
- [ ] **Step 1: Add assertions to the popup modal test**
|
||||
|
||||
In `src/__tests__/unit/AppDetailModal.test.ts`, update `renders detail content inside a popup-style modal overlay` so the body after the `.modal-panel` assertion is:
|
||||
|
||||
```ts
|
||||
const panel = overlay?.querySelector(".modal-panel");
|
||||
expect(panel).toBeTruthy();
|
||||
expect(panel?.className).toContain("overflow-hidden");
|
||||
expect(panel?.className).toContain("lg:max-h-[85vh]");
|
||||
expect(panel?.querySelector('[data-testid="detail-fixed-sidebar"]')).toBeTruthy();
|
||||
expect(panel?.querySelector('[data-testid="detail-scroll-content"]')).toBeTruthy();
|
||||
expect(
|
||||
panel?.querySelector('[data-testid="detail-scroll-content"]')?.className,
|
||||
).toContain("lg:overflow-y-auto");
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify failure**
|
||||
|
||||
Run: `npm run test -- --run src/__tests__/unit/AppDetailModal.test.ts`
|
||||
|
||||
Expected: FAIL because `data-testid="detail-fixed-sidebar"`, `data-testid="detail-scroll-content"`, and the new modal classes are not present.
|
||||
|
||||
## Task 2: Implement Fixed Sidebar Layout
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/AppDetailModal.vue`
|
||||
- Modify: `src/__tests__/unit/AppDetailModal.test.ts`
|
||||
|
||||
- [ ] **Step 1: Update modal shell classes**
|
||||
|
||||
In `src/components/AppDetailModal.vue`, change the `.modal-panel` class from:
|
||||
|
||||
```vue
|
||||
class="modal-panel relative w-full max-w-5xl max-h-[85vh] overflow-y-auto overscroll-contain scrollbar-nowidth rounded-3xl border border-white/10 bg-white/95 px-6 pb-6 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```vue
|
||||
class="modal-panel relative flex w-full max-w-5xl max-h-[85vh] overflow-y-auto overscroll-contain scrollbar-nowidth rounded-3xl border border-white/10 bg-white/95 px-6 pb-6 shadow-2xl dark:border-slate-800 dark:bg-slate-900 lg:max-h-[85vh] lg:overflow-hidden lg:pb-0"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Move the return button into the fixed sidebar**
|
||||
|
||||
In `src/components/AppDetailModal.vue`, replace the top-level return button and main layout start with:
|
||||
|
||||
```vue
|
||||
<!-- 主布局:左侧信息 + 右侧内容 -->
|
||||
<div class="flex w-full flex-col gap-6 lg:min-h-0 lg:flex-row">
|
||||
<!-- 左侧:图标、版本、来源、按钮、元信息 -->
|
||||
<div
|
||||
data-testid="detail-fixed-sidebar"
|
||||
class="w-full flex-shrink-0 space-y-5 lg:w-72 lg:self-start lg:py-4"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 rounded-full border border-slate-200/70 bg-white/90 px-4 py-2 text-sm font-medium text-slate-600 shadow-lg backdrop-blur-sm transition hover:bg-slate-50 hover:text-slate-900 dark:border-slate-700 dark:bg-slate-800/90 dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-200"
|
||||
@click="closeModal"
|
||||
aria-label="返回"
|
||||
>
|
||||
<i class="fas fa-arrow-left"></i>
|
||||
<span>返回</span>
|
||||
</button>
|
||||
```
|
||||
|
||||
Remove the old sticky top-level return button and the old left column opening:
|
||||
|
||||
```vue
|
||||
<!-- 返回按钮 - sticky定位在模态框内部左上角,滚动时始终可见 -->
|
||||
<button ...>...</button>
|
||||
|
||||
<!-- 主布局:左侧信息 + 右侧内容 -->
|
||||
<div class="flex flex-col lg:flex-row gap-6">
|
||||
<!-- 左侧:图标、版本、来源、按钮、元信息 -->
|
||||
<div class="w-full lg:w-72 flex-shrink-0 space-y-5">
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Make the right column the desktop scroll container**
|
||||
|
||||
In `src/components/AppDetailModal.vue`, change the right column opening from:
|
||||
|
||||
```vue
|
||||
<div class="flex-1 min-w-0 space-y-5">
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```vue
|
||||
<div
|
||||
data-testid="detail-scroll-content"
|
||||
class="min-w-0 flex-1 space-y-5 lg:max-h-[85vh] lg:overflow-y-auto lg:overscroll-contain lg:py-4 lg:pr-2"
|
||||
>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update scroll reset target if needed**
|
||||
|
||||
In `src/App.vue`, keep the existing modal scroll reset selector if it still points at `.modal-panel`. If the right column needs reset instead, change the query to:
|
||||
|
||||
```ts
|
||||
const modal = document.querySelector(
|
||||
'[data-app-modal="detail"] [data-testid="detail-scroll-content"]',
|
||||
);
|
||||
```
|
||||
|
||||
Use `modal.scrollTop = 0` as it does today.
|
||||
|
||||
- [ ] **Step 5: Run focused test**
|
||||
|
||||
Run: `npm run test -- --run src/__tests__/unit/AppDetailModal.test.ts`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Run build verification**
|
||||
|
||||
Run: `npm run build:vite`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 7: Commit implementation**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add src/components/AppDetailModal.vue src/App.vue src/__tests__/unit/AppDetailModal.test.ts
|
||||
git commit -m "fix(ui): pin detail modal sidebar"
|
||||
```
|
||||
|
||||
Expected: commit succeeds.
|
||||
@@ -0,0 +1,141 @@
|
||||
# Review Avatar Display Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Show cached backend user avatar URLs beside comments in the app review list.
|
||||
|
||||
**Architecture:** Use the existing `AppReview.userAvatarUrl` field returned by the backend. `ReviewsPanel.vue` renders an avatar image when present and a stable fallback when missing or failed; tests cover the rendered avatar contract.
|
||||
|
||||
**Tech Stack:** Vue 3 SFC, Tailwind CSS utilities, Vitest, Testing Library Vue.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- Modify: `src/components/ReviewsPanel.vue` - render reviewer avatar/fallback in each review card and handle broken avatar images.
|
||||
- Modify: `src/__tests__/unit/ReviewsPanel.test.ts` - add test coverage for avatar display.
|
||||
|
||||
## Task 1: Add Avatar Rendering Test
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/__tests__/unit/ReviewsPanel.test.ts`
|
||||
|
||||
- [ ] **Step 1: Add review with avatar test**
|
||||
|
||||
Append this test inside `describe("ReviewsPanel", () => { ... })` before the final closing brace:
|
||||
|
||||
```ts
|
||||
it("shows reviewer avatars when available", async () => {
|
||||
vi.mocked(fetchRatingSummary).mockResolvedValue({
|
||||
averageRating: 5,
|
||||
reviewCount: 1,
|
||||
starCounts: { 5: 1 },
|
||||
});
|
||||
vi.mocked(fetchReviews).mockResolvedValue([
|
||||
{
|
||||
id: 3,
|
||||
rating: 5,
|
||||
content: "头像正常显示",
|
||||
version: tags.version,
|
||||
packageArch: tags.packageArch,
|
||||
clientArch: tags.clientArch,
|
||||
distro: tags.distro,
|
||||
origin: tags.origin,
|
||||
category: tags.category,
|
||||
createdAt: "2026-05-19T00:00:00Z",
|
||||
updatedAt: "2026-05-19T00:00:00Z",
|
||||
userDisplayName: "Avatar User",
|
||||
userAvatarUrl: "https://bbs.spark-app.store/avatar.png",
|
||||
},
|
||||
]);
|
||||
|
||||
render(ReviewsPanel, {
|
||||
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: true },
|
||||
});
|
||||
|
||||
const avatar = await screen.findByAltText("Avatar User 的头像");
|
||||
expect(avatar).toHaveAttribute("src", "https://bbs.spark-app.store/avatar.png");
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify failure**
|
||||
|
||||
Run: `npm run test -- --run src/__tests__/unit/ReviewsPanel.test.ts`
|
||||
|
||||
Expected: FAIL because no avatar image with alt text is rendered.
|
||||
|
||||
## Task 2: Render Review Avatars
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/ReviewsPanel.vue`
|
||||
- Modify: `src/__tests__/unit/ReviewsPanel.test.ts`
|
||||
|
||||
- [ ] **Step 1: Update review article layout**
|
||||
|
||||
In `src/components/ReviewsPanel.vue`, replace the review `<article>` body with this structure:
|
||||
|
||||
```vue
|
||||
<div class="flex gap-3">
|
||||
<img
|
||||
v-if="review.userAvatarUrl"
|
||||
:src="review.userAvatarUrl"
|
||||
:alt="`${review.userDisplayName || '星火用户'} 的头像`"
|
||||
class="h-9 w-9 flex-shrink-0 rounded-full bg-slate-100 object-cover dark:bg-slate-800"
|
||||
loading="lazy"
|
||||
referrerpolicy="no-referrer"
|
||||
@error="hideAvatar"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full bg-slate-200 text-xs font-semibold text-slate-500 dark:bg-slate-800 dark:text-slate-300"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{{ review.userDisplayName?.slice(0, 1) || "星" }}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<strong class="truncate text-slate-700 dark:text-slate-200">
|
||||
{{ review.userDisplayName || "星火用户" }}
|
||||
</strong>
|
||||
<span class="flex-shrink-0 text-xs text-slate-400">{{ review.rating }} 星</span>
|
||||
</div>
|
||||
<p class="mt-2 whitespace-pre-wrap text-slate-600 dark:text-slate-300">
|
||||
{{ review.content || "暂无评论内容" }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add avatar error handler**
|
||||
|
||||
In the `<script setup>` block of `src/components/ReviewsPanel.vue`, add:
|
||||
|
||||
```ts
|
||||
const hideAvatar = (event: Event) => {
|
||||
(event.target as HTMLElement).style.display = "none";
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run focused test**
|
||||
|
||||
Run: `npm run test -- --run src/__tests__/unit/ReviewsPanel.test.ts`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Run build verification**
|
||||
|
||||
Run: `npm run build:vite`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit and push**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add docs/superpowers/plans/2026-05-19-review-avatar-display.md src/components/ReviewsPanel.vue src/__tests__/unit/ReviewsPanel.test.ts
|
||||
git commit -m "feat(reviews): show reviewer avatars"
|
||||
git push origin Erotica
|
||||
```
|
||||
|
||||
Expected: commit and push succeed.
|
||||
@@ -0,0 +1,284 @@
|
||||
# Client UI Polish Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Fix the client-side account, favorites, reviews, sync, restore, and shell UI issues reported in QA.
|
||||
|
||||
**Architecture:** Preserve existing Vue component boundaries. Add thin wrappers/helpers for modal account management, custom titlebar, review star controls, and sync restore candidate resolution. Backend-dependent review actions are client-side affordances only until backend endpoints exist.
|
||||
|
||||
**Tech Stack:** Vue 3 Composition API, TypeScript strict mode, Electron IPC, Tailwind CSS, Vitest, Testing Library Vue.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- Create `src/components/UserManagementModal.vue` to present `UserManagementView` as a global overlay.
|
||||
- Create `src/components/WindowTitleBar.vue` for frameless titlebar UI and window controls.
|
||||
- Modify `src/App.vue` to mount the new account modal, pass sync feedback to installed apps modal, pass favorite status to detail modal, and use restore candidate helper.
|
||||
- Modify `src/components/AppSidebar.vue` and `src/components/AccountQuickMenu.vue` for overflow-safe labels and menu closing coverage.
|
||||
- Modify `src/components/UserManagementView.vue` for cover-image rendering and modal-friendly layout.
|
||||
- Modify `src/components/InstalledAppsModal.vue` to show sync feedback locally.
|
||||
- Modify `src/components/FavoriteFolderSelector.vue` for normalized default-folder de-dupe and favorited-state text/actions.
|
||||
- Modify `src/components/FavoriteFolderManager.vue` so the entire row content opens detail while checkbox stays isolated.
|
||||
- Modify `src/components/AppDetailModal.vue` and `src/components/AppDetailPage.vue` to show `已收藏` state.
|
||||
- Modify `src/components/ReviewsPanel.vue` for star rating, filters, user detail affordance, and disabled backend-dependent actions.
|
||||
- Modify `src/modules/appListSync.ts` for `resolveCloudInstallCandidate()`.
|
||||
- Modify `electron/main/index.ts`, `electron/preload/index.ts`, and `src/vite-env.d.ts` for frameless window controls.
|
||||
- Add/update unit tests under `src/__tests__/unit/`.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Account Menu And User Management Modal
|
||||
|
||||
**Files:**
|
||||
- Create: `src/components/UserManagementModal.vue`
|
||||
- Modify: `src/App.vue`
|
||||
- Modify: `src/components/AppSidebar.vue`
|
||||
- Modify: `src/components/AccountQuickMenu.vue`
|
||||
- Modify: `src/components/UserManagementView.vue`
|
||||
- Modify: `src/global/typedefinition.ts`
|
||||
- Modify: `src/modules/backendApi.ts`
|
||||
- Modify: `src/global/authState.ts`
|
||||
- Test: `src/__tests__/unit/AppSidebar.account.test.ts`
|
||||
- Test: `src/__tests__/unit/UserManagementView.test.ts`
|
||||
- Test: `src/__tests__/unit/App.account-placeholders.test.ts`
|
||||
- Test: `src/__tests__/unit/accountTypes.test.ts`
|
||||
- Test: `src/__tests__/unit/authState.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing sidebar tests**
|
||||
|
||||
Add tests to `src/__tests__/unit/AppSidebar.account.test.ts` that verify each quick-menu action closes the menu, and that a user with empty `displayName` and long `username` still gets truncate/min-width classes.
|
||||
|
||||
- [ ] **Step 2: Run sidebar tests red**
|
||||
|
||||
Run: `npm run test -- --run src/__tests__/unit/AppSidebar.account.test.ts`
|
||||
|
||||
Expected: FAIL for missing fallback username coverage or quick-menu item truncation coverage.
|
||||
|
||||
- [ ] **Step 3: Implement sidebar overflow and close safety**
|
||||
|
||||
Update account label wrappers and quick-menu item labels with `min-w-0`, `truncate`, and explicit close-before-emit behavior already used by the parent.
|
||||
|
||||
- [ ] **Step 4: Write failing user management modal tests**
|
||||
|
||||
Update `src/__tests__/unit/App.account-placeholders.test.ts` so opening “用户管理” asserts a `role="dialog"` overlay exists and the main app grid/favorites frame is not replaced by `currentView === 'account'`.
|
||||
|
||||
- [ ] **Step 5: Write failing cover field tests**
|
||||
|
||||
Update `src/__tests__/unit/accountTypes.test.ts`, `authState.test.ts`, and `UserManagementView.test.ts` for optional `coverUrl` on `SparkUser` and visible cover rendering.
|
||||
|
||||
- [ ] **Step 6: Implement modal and cover rendering**
|
||||
|
||||
Add `UserManagementModal.vue`, add `showUserManagementModal` state in `App.vue`, keep `currentView` unchanged when opening user management, and pass through `close`, `open-forum`, `edit-profile`, `toggle-sync`, `sync-now`, and `refresh-downloads` events.
|
||||
|
||||
- [ ] **Step 7: Run account tests green**
|
||||
|
||||
Run: `npm run test -- --run src/__tests__/unit/AppSidebar.account.test.ts src/__tests__/unit/UserManagementView.test.ts src/__tests__/unit/App.account-placeholders.test.ts src/__tests__/unit/accountTypes.test.ts src/__tests__/unit/authState.test.ts`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Favorites State And Interaction Polish
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/App.vue`
|
||||
- Modify: `src/components/FavoriteFolderSelector.vue`
|
||||
- Modify: `src/components/FavoriteFolderManager.vue`
|
||||
- Modify: `src/components/AppDetailModal.vue`
|
||||
- Modify: `src/components/AppDetailPage.vue`
|
||||
- Modify: `src/modules/backendApi.ts`
|
||||
- Test: `src/__tests__/unit/FavoriteFolderSelector.test.ts`
|
||||
- Test: `src/__tests__/unit/FavoriteFolderManager.test.ts`
|
||||
- Test: `src/__tests__/unit/AppDetailModal.test.ts`
|
||||
- Test: `src/__tests__/unit/App.account-placeholders.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing selector de-dupe tests**
|
||||
|
||||
Add a test where backend returns folder name ` 默认收藏夹 ` and assert only one default folder button appears.
|
||||
|
||||
- [ ] **Step 2: Run selector test red**
|
||||
|
||||
Run: `npm run test -- --run src/__tests__/unit/FavoriteFolderSelector.test.ts`
|
||||
|
||||
Expected: FAIL because current de-dupe compares exact name only.
|
||||
|
||||
- [ ] **Step 3: Normalize default folder names**
|
||||
|
||||
Trim folder names before default-folder comparison.
|
||||
|
||||
- [ ] **Step 4: Write failing detail favorite-state tests**
|
||||
|
||||
Add tests asserting a favorited app renders `已收藏` in `AppDetailModal` and emits the favorite action when clicked.
|
||||
|
||||
- [ ] **Step 5: Implement favorite state props**
|
||||
|
||||
Add `favorited`/`favoriteFolderName` props to detail components and compute current favorite metadata in `App.vue` from loaded folders/items.
|
||||
|
||||
- [ ] **Step 6: Write failing row click test**
|
||||
|
||||
Add a test that clicking the favorite row text/image area opens detail while clicking the checkbox only selects.
|
||||
|
||||
- [ ] **Step 7: Implement row-click behavior**
|
||||
|
||||
Make the favorite row content area larger/clickable and keep checkbox event isolated.
|
||||
|
||||
- [ ] **Step 8: Run favorites tests green**
|
||||
|
||||
Run: `npm run test -- --run src/__tests__/unit/FavoriteFolderSelector.test.ts src/__tests__/unit/FavoriteFolderManager.test.ts src/__tests__/unit/AppDetailModal.test.ts src/__tests__/unit/App.account-placeholders.test.ts`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Review Panel Client UX
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/ReviewsPanel.vue`
|
||||
- Modify: `src/global/typedefinition.ts`
|
||||
- Modify: `src/modules/backendApi.ts`
|
||||
- Test: `src/__tests__/unit/ReviewsPanel.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing star rating test**
|
||||
|
||||
Add a test that clicks the “3 星” star button and verifies `submitReview` receives `rating: 3`.
|
||||
|
||||
- [ ] **Step 2: Write failing filter test**
|
||||
|
||||
Add reviews with different `packageArch` and `distro`; assert selecting an architecture or OS filter hides non-matching reviews.
|
||||
|
||||
- [ ] **Step 3: Write failing review action/user tests**
|
||||
|
||||
Add a test that reviewer avatar/name are buttons emitting/showing user detail affordance, and that like/reply/delete controls render with delete disabled unless local permission allows it.
|
||||
|
||||
- [ ] **Step 4: Run review tests red**
|
||||
|
||||
Run: `npm run test -- --run src/__tests__/unit/ReviewsPanel.test.ts`
|
||||
|
||||
Expected: FAIL because current UI uses select rating and lacks filters/actions.
|
||||
|
||||
- [ ] **Step 5: Implement star rating and filters**
|
||||
|
||||
Replace the native rating select with five star buttons and add local filter controls for package architecture and distro.
|
||||
|
||||
- [ ] **Step 6: Implement client-only review actions**
|
||||
|
||||
Render like/reply/delete buttons. Like/reply show a local “后端接口接入后可用” message. Delete is visible only for author/admin-compatible client data; otherwise omit or disable it.
|
||||
|
||||
- [ ] **Step 7: Run review tests green**
|
||||
|
||||
Run: `npm run test -- --run src/__tests__/unit/ReviewsPanel.test.ts`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Sync Feedback And Cross-Origin Restore
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/App.vue`
|
||||
- Modify: `src/components/InstalledAppsModal.vue`
|
||||
- Modify: `src/modules/appListSync.ts`
|
||||
- Test: `src/__tests__/unit/appListSync.test.ts`
|
||||
- Test: `src/__tests__/unit/App.account-placeholders.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing restore helper tests**
|
||||
|
||||
Add `resolveCloudInstallCandidate()` tests for exact origin/category match, same package fallback across origin, and no candidate.
|
||||
|
||||
- [ ] **Step 2: Run sync helper tests red**
|
||||
|
||||
Run: `npm run test -- --run src/__tests__/unit/appListSync.test.ts`
|
||||
|
||||
Expected: FAIL because helper is missing.
|
||||
|
||||
- [ ] **Step 3: Implement restore helper**
|
||||
|
||||
Export `resolveCloudInstallCandidate(item, apps)` from `appListSync.ts` and use it in `App.vue` `installCloudItems()`.
|
||||
|
||||
- [ ] **Step 4: Write failing sync feedback test**
|
||||
|
||||
Update `App.account-placeholders.test.ts` or `InstalledAppsModal.test.ts` to assert clicking sync in the installed-apps modal shows `同步完成` or an error message in that modal.
|
||||
|
||||
- [ ] **Step 5: Implement modal sync feedback prop**
|
||||
|
||||
Add `syncMessage` prop to `InstalledAppsModal.vue` and pass `syncStatusMessage` from `App.vue`.
|
||||
|
||||
- [ ] **Step 6: Run sync tests green**
|
||||
|
||||
Run: `npm run test -- --run src/__tests__/unit/appListSync.test.ts src/__tests__/unit/App.account-placeholders.test.ts src/__tests__/unit/InstalledAppsModal.test.ts`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Frameless Window And Shell Polish
|
||||
|
||||
**Files:**
|
||||
- Create: `src/components/WindowTitleBar.vue`
|
||||
- Modify: `src/App.vue`
|
||||
- Modify: `electron/main/index.ts`
|
||||
- Modify: `electron/preload/index.ts`
|
||||
- Modify: `src/vite-env.d.ts`
|
||||
- Test: add or update appropriate unit tests for titlebar component and source config.
|
||||
|
||||
- [ ] **Step 1: Write failing titlebar component test**
|
||||
|
||||
Create a unit test that renders `WindowTitleBar.vue`, clicks minimize/maximize/close, and verifies IPC messages are sent.
|
||||
|
||||
- [ ] **Step 2: Write failing Electron config test or static check**
|
||||
|
||||
Add a lightweight test/static assertion that `BrowserWindow` is created with `frame: false`.
|
||||
|
||||
- [ ] **Step 3: Implement IPC handlers and titlebar**
|
||||
|
||||
Set `frame: false` in `BrowserWindow`; add IPC listeners for window minimize, toggle maximize, and close using existing close guard; add `WindowTitleBar.vue` with drag/no-drag regions.
|
||||
|
||||
- [ ] **Step 4: Mount titlebar in App.vue**
|
||||
|
||||
Place the titlebar at the top of the root layout and adjust sticky header offsets as needed.
|
||||
|
||||
- [ ] **Step 5: Verify category capsule color**
|
||||
|
||||
Run existing `CategoryBar.test.ts`; no code change needed if it already asserts `#2b7fff`.
|
||||
|
||||
Run: `npm run test -- --run src/__tests__/unit/CategoryBar.test.ts`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Final Verification
|
||||
|
||||
**Files:**
|
||||
- All files touched by previous tasks.
|
||||
|
||||
- [ ] **Step 1: Run targeted unit tests**
|
||||
|
||||
Run the union of tests touched by this plan:
|
||||
|
||||
```bash
|
||||
npm run test -- --run src/__tests__/unit/AppSidebar.account.test.ts src/__tests__/unit/UserManagementView.test.ts src/__tests__/unit/App.account-placeholders.test.ts src/__tests__/unit/accountTypes.test.ts src/__tests__/unit/authState.test.ts src/__tests__/unit/FavoriteFolderSelector.test.ts src/__tests__/unit/FavoriteFolderManager.test.ts src/__tests__/unit/AppDetailModal.test.ts src/__tests__/unit/ReviewsPanel.test.ts src/__tests__/unit/appListSync.test.ts src/__tests__/unit/InstalledAppsModal.test.ts src/__tests__/unit/CategoryBar.test.ts
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 2: Run production build**
|
||||
|
||||
Run: `npm run build:vite`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 3: Check diff whitespace**
|
||||
|
||||
Run: `git diff --check`
|
||||
|
||||
Expected: no output.
|
||||
|
||||
- [ ] **Step 4: Commit client changes**
|
||||
|
||||
Commit message: `fix(ui): polish account favorites reviews and shell`
|
||||
|
||||
- [ ] **Step 5: Push feature branch**
|
||||
|
||||
Push `fix/client-ui-account-favorites` for review or merge.
|
||||
@@ -0,0 +1,229 @@
|
||||
# 更新中心图标逐级回退设计
|
||||
|
||||
## 背景
|
||||
|
||||
当前更新中心的图标解析分成两段:
|
||||
|
||||
1. 主进程 `electron/main/backend/update-center/icons.ts` 会优先解析本地图标,解析不到时再直接返回线上图标 URL。
|
||||
2. 渲染层 `src/components/update-center/UpdateCenterItem.vue` 只接收单个 `icon` 字段,图片加载失败后直接回退到默认占位图。
|
||||
|
||||
这个结构已经满足“本地优先”的静态选择,但不能满足新的行为要求:
|
||||
|
||||
1. 当本地图标成功加载时,不再请求线上图标和默认图标。
|
||||
2. 当本地图标路径虽然存在、但实际加载失败时,继续尝试线上图标。
|
||||
3. 当线上图标也失败时,最后才回退到默认占位图。
|
||||
|
||||
问题根因不是路径优先级判断错误,而是当前前后端只传递了一个最终 `icon` 值,导致前端无法在运行时根据真实加载结果继续尝试下一层来源。
|
||||
|
||||
## 目标
|
||||
|
||||
1. 更新中心图标加载顺序固定为:`localIcon -> remoteIcon -> placeholder`。
|
||||
2. 本地图标加载成功时,不再加载线上图标和默认图标。
|
||||
3. 本地图标加载失败时,自动切换到线上图标。
|
||||
4. 线上图标也失败时,才显示默认占位图。
|
||||
5. 保持当前更新中心列表布局、图标尺寸和已有解析路径规则不变。
|
||||
|
||||
## 非目标
|
||||
|
||||
1. 不改动主商店、已安装列表或其他页面的图标逻辑。
|
||||
2. 不增加新的网络探测请求,也不预检远程图标是否可访问。
|
||||
3. 不重构现有本地图标解析算法,只调整数据结构和回退链路。
|
||||
4. 不引入通用的图标来源数组或复杂图标对象。
|
||||
|
||||
## 方案选择
|
||||
|
||||
本次考虑过三种方案:
|
||||
|
||||
1. 后端透传 `localIcon` 和 `remoteIcon` 两个字段,前端顺序尝试。
|
||||
2. 后端透传 `iconCandidates: string[]`,前端按数组顺序尝试。
|
||||
3. 继续只传一个 `icon`,前端根据 `pkgname/category/arch` 自己重新拼线上图标地址。
|
||||
|
||||
最终选择方案 1。
|
||||
|
||||
原因:
|
||||
|
||||
1. 它刚好对应本次明确的三级回退需求,最小且直接。
|
||||
2. 后端继续掌握图标来源规则,避免前端复制商店 URL 规则。
|
||||
3. 相比数组方案,双字段更易读、更容易在 IPC 类型中维护。
|
||||
4. 前端只负责“加载失败后切换到下一来源”,职责边界清晰。
|
||||
|
||||
## 设计概览
|
||||
|
||||
更新中心改为“主进程解析来源,渲染层控制加载顺序”的结构:
|
||||
|
||||
1. 主进程为每个更新项分别计算 `localIcon` 和 `remoteIcon`。
|
||||
2. 服务层和前端类型透传这两个字段。
|
||||
3. `UpdateCenterItem.vue` 按 `localIcon -> remoteIcon -> placeholder` 的顺序逐级尝试。
|
||||
4. 候选图标一旦成功加载,组件不再切换到后续来源。
|
||||
|
||||
## 数据结构变更
|
||||
|
||||
### 主进程类型
|
||||
|
||||
修改:`electron/main/backend/update-center/types.ts`
|
||||
|
||||
将:
|
||||
|
||||
```ts
|
||||
icon?: string;
|
||||
```
|
||||
|
||||
改为:
|
||||
|
||||
```ts
|
||||
localIcon?: string;
|
||||
remoteIcon?: string;
|
||||
```
|
||||
|
||||
### Service Snapshot
|
||||
|
||||
修改:`electron/main/backend/update-center/service.ts`
|
||||
|
||||
更新 renderer-facing item/task 类型,并在 `toState()` 中透传:
|
||||
|
||||
```ts
|
||||
localIcon?: string;
|
||||
remoteIcon?: string;
|
||||
```
|
||||
|
||||
### 渲染层类型
|
||||
|
||||
修改:`src/global/typedefinition.ts`
|
||||
|
||||
更新 `UpdateCenterItem` 和 `UpdateCenterTaskState`:
|
||||
|
||||
```ts
|
||||
localIcon?: string;
|
||||
remoteIcon?: string;
|
||||
```
|
||||
|
||||
## 模块边界
|
||||
|
||||
### `electron/main/backend/update-center/icons.ts`
|
||||
|
||||
保留现有职责,但返回内容从“单个最终图标”调整为“两种候选来源”:
|
||||
|
||||
1. `resolveDesktopIcon(pkgname)`:解析传统 deb / aptss 更新项的本地图标。
|
||||
2. `resolveApmIcon(pkgname)`:解析 APM 更新项的本地图标。
|
||||
3. `buildRemoteFallbackIconUrl(item)`:拼接远程商店图标地址。
|
||||
4. `resolveUpdateItemIcons(item)`:组合出 `{ localIcon?, remoteIcon? }`。
|
||||
|
||||
这里不再提前做“本地失败就直接放弃线上”的最终决策,而是把两个候选来源都准备好交给前端。
|
||||
|
||||
### `electron/main/backend/update-center/index.ts`
|
||||
|
||||
在更新项 enrichment 阶段,将:
|
||||
|
||||
1. 现有的单 `icon` 注入逻辑。
|
||||
|
||||
调整为:
|
||||
|
||||
1. 读取 `resolveUpdateItemIcons(item)` 的结果。
|
||||
2. 仅在字段存在时把 `localIcon` / `remoteIcon` 写回更新项。
|
||||
|
||||
### `src/components/update-center/UpdateCenterItem.vue`
|
||||
|
||||
组件不再把单个 `item.icon` 当成最终地址,而是:
|
||||
|
||||
1. 从 `item.localIcon` 和 `item.remoteIcon` 派生候选列表。
|
||||
2. 使用当前索引决定 `img.src`。
|
||||
3. 失败时切到下一候选项。
|
||||
4. 候选项耗尽后切到占位图。
|
||||
|
||||
## 详细数据流
|
||||
|
||||
### 主进程加载更新项
|
||||
|
||||
1. 更新中心主进程加载更新项。
|
||||
2. 现有逻辑继续补齐 `category`、`arch` 等字段。
|
||||
3. 图标模块为每个项分别解析:
|
||||
- `localIcon`:本地图标路径。
|
||||
- `remoteIcon`:线上图标 URL。
|
||||
4. enrichment 后的更新项通过 service snapshot 发送到渲染层。
|
||||
|
||||
### 渲染层展示更新项
|
||||
|
||||
1. 组件收到 `item.localIcon` / `item.remoteIcon`。
|
||||
2. 组件构造一个有序候选列表:
|
||||
- 本地路径转换为 `file://` URL。
|
||||
- 远程 URL 原样使用。
|
||||
3. 初始渲染第 1 个候选图标。
|
||||
4. 如果 `img` 加载成功,流程结束,不再切换到下一项。
|
||||
5. 如果 `img` 触发 `error`,索引递增,继续尝试下一候选图标。
|
||||
6. 如果所有候选都失败,切换到占位图。
|
||||
|
||||
## 前端行为细节
|
||||
|
||||
### 候选列表生成规则
|
||||
|
||||
候选列表只包含存在且非空的来源:
|
||||
|
||||
1. `localIcon` 存在时放在第 1 位。
|
||||
2. `remoteIcon` 存在时放在第 2 位。
|
||||
3. 占位图不放入候选列表,而是在候选耗尽后单独回退。
|
||||
|
||||
这样可以避免:
|
||||
|
||||
1. 本地图标成功时还额外发起线上请求。
|
||||
2. 图标字段为空时出现无意义的重试。
|
||||
|
||||
### 状态重置规则
|
||||
|
||||
当 `props.item` 变为新的更新项对象时:
|
||||
|
||||
1. 重置当前候选索引到第 1 项。
|
||||
2. 清空“候选已耗尽”的状态。
|
||||
3. 重新开始本地优先的尝试流程。
|
||||
|
||||
这样可确保列表复用或重新渲染时,新条目不会继承上一条目的失败状态。
|
||||
|
||||
### 占位图规则
|
||||
|
||||
保留当前组件内默认占位 SVG,不改样式和尺寸。
|
||||
|
||||
只有在以下情况下才使用占位图:
|
||||
|
||||
1. `localIcon` 和 `remoteIcon` 都不存在。
|
||||
2. `localIcon` 加载失败且 `remoteIcon` 不存在。
|
||||
3. `localIcon` 和 `remoteIcon` 都加载失败。
|
||||
|
||||
## 错误处理
|
||||
|
||||
1. 本地图标路径不存在或不可读:允许浏览器触发加载失败,再由前端切到线上图标。
|
||||
2. 远程图标返回 404、超时或其他加载错误:前端切到占位图,不向用户弹额外错误。
|
||||
3. 后端无法推断 `category` 或 `arch`:允许 `remoteIcon` 为空,前端只尝试本地图标和占位图。
|
||||
4. 任一图标来源失败都不能影响更新列表正文、状态标签和进度条显示。
|
||||
|
||||
## 测试方案
|
||||
|
||||
### 后端测试
|
||||
|
||||
扩展 `src/__tests__/unit/update-center/icons.test.ts`:
|
||||
|
||||
1. 本地图标可解析时,`resolveUpdateItemIcons()` 返回 `localIcon`,并在条件满足时同时包含 `remoteIcon`。
|
||||
2. 本地图标缺失时,仍可返回 `remoteIcon`。
|
||||
3. 缺少 `category` 或 `arch` 时,不返回 `remoteIcon`。
|
||||
4. 两者都不可得时,返回空对象。
|
||||
|
||||
### 组件测试
|
||||
|
||||
扩展 `src/__tests__/unit/update-center/UpdateCenterItem.test.ts`:
|
||||
|
||||
1. 有 `localIcon` 时先渲染本地 `file://` 地址。
|
||||
2. 本地图标未失败前,不切换到 `remoteIcon`。
|
||||
3. 本地图标触发 `error` 后切到 `remoteIcon`。
|
||||
4. 本地和线上都触发 `error` 后切到默认占位图。
|
||||
5. 切换到新的 `item` 后,回退状态会重置。
|
||||
|
||||
## 风险与约束
|
||||
|
||||
1. 如果某些包的本地图标路径在后端看来存在,但渲染进程实际不可访问,仍会触发一次失败请求;这是预期行为,因为它正是继续尝试线上图标的触发条件。
|
||||
2. 远程图标 URL 继续依赖当前商店路径规则,若个别包没有线上图标,最终仍会使用占位图。
|
||||
3. 本次只调整更新中心图标链路,不同步抽象其他页面,避免扩大改动范围。
|
||||
|
||||
## 决策总结
|
||||
|
||||
1. 用 `localIcon` 和 `remoteIcon` 替代单个 `icon` 字段。
|
||||
2. 主进程负责解析来源,渲染层负责按顺序加载和失败回退。
|
||||
3. 固定回退顺序为:本地图标 -> 线上图标 -> 默认占位图。
|
||||
4. 本地图标成功时,不再加载线上图标和默认图标。
|
||||
@@ -0,0 +1,169 @@
|
||||
# 更新中心迁移更新策略设计
|
||||
|
||||
## 背景
|
||||
|
||||
当前更新中心会同时拉取 `aptss` 和 `apm` 的可更新列表,并按包名合并展示。现有行为中,双源同名更新通常会显示两条记录;即使标记了“迁移”,也不会真正执行“卸载 aptss 后安装 apm”的迁移流程。
|
||||
|
||||
目标是把更新策略调整为以已安装来源为主,并在 `aptss -> apm` 迁移场景中提供明确、单一且可确认的更新入口。
|
||||
|
||||
## 目标行为
|
||||
|
||||
### 1. 仅安装了 aptss 版本
|
||||
|
||||
- 同时检查 `aptss` 和 `apm` 是否有同名更新。
|
||||
- 如果只有 `aptss` 有更新:显示一条普通 `aptss` 更新记录。
|
||||
- 如果 `apm` 也有同名更新,且 `apm` 的目标版本高于 `aptss`:
|
||||
- 只显示一条迁移更新记录。
|
||||
- 该记录的展示语义为“将迁移到 APM 管理”。
|
||||
- 不再显示对应的普通 `aptss` 更新记录。
|
||||
- 用户确认迁移后,执行:
|
||||
1. `shell-caller.sh aptss remove <pkg>`
|
||||
2. 安装 `apm` 版本。
|
||||
|
||||
### 2. 仅安装了 apm 版本
|
||||
|
||||
- 只检查并展示 `apm` 的同名更新。
|
||||
- 即使 `aptss` 存在同名更新,也不在更新中心中展示。
|
||||
|
||||
### 3. 同时安装了 aptss 与 apm 版本
|
||||
|
||||
- 同时展示两条更新记录。
|
||||
- `aptss` 记录更新 `aptss` 安装位置。
|
||||
- `apm` 记录更新 `apm` 安装位置。
|
||||
- 两条记录互不替代,也不触发迁移逻辑。
|
||||
|
||||
## 数据模型调整
|
||||
|
||||
### UpdateCenterItem
|
||||
|
||||
保留现有字段,并继续使用以下迁移字段:
|
||||
|
||||
- `isMigration?: boolean`
|
||||
- `migrationSource?: "aptss" | "apm"`
|
||||
- `migrationTarget?: "aptss" | "apm"`
|
||||
- `aptssVersion?: string`
|
||||
|
||||
迁移记录仍以 `source: "apm"` 表示最终安装来源,但其语义从“推荐迁移”改为“唯一展示的迁移更新入口”。
|
||||
|
||||
## 列表合并规则
|
||||
|
||||
更新 `mergeUpdateSources()` 的逻辑,使其按安装来源状态决定展示结果,而不是单纯把双源结果并列展示。
|
||||
|
||||
### 情况 A:仅 aptss 安装
|
||||
|
||||
条件:`installedState.aptss === true && installedState.apm === false`
|
||||
|
||||
- 若只有 `aptss` 更新:返回 `aptss` 记录。
|
||||
- 若只有 `apm` 更新:不展示该条记录。
|
||||
- 若两者都有:
|
||||
- 如果 `apm.nextVersion > aptss.nextVersion`:
|
||||
- 只返回一条迁移记录,基于 `apmItem` 构造。
|
||||
- 设置 `isMigration: true`、`migrationSource: "aptss"`、`migrationTarget: "apm"`。
|
||||
- 保存 `aptssVersion` 供 UI 展示。
|
||||
- 否则:只返回 `aptss` 记录。
|
||||
|
||||
### 情况 B:仅 apm 安装
|
||||
|
||||
条件:`installedState.aptss === false && installedState.apm === true`
|
||||
|
||||
- 若 `apm` 有更新:返回 `apm` 记录。
|
||||
- 忽略同名 `aptss` 更新。
|
||||
|
||||
### 情况 C:同时安装 aptss 与 apm
|
||||
|
||||
条件:`installedState.aptss === true && installedState.apm === true`
|
||||
|
||||
- 若两者都有更新:同时返回两条记录。
|
||||
- 若只有其中一方有更新:只返回对应来源的记录。
|
||||
|
||||
### 情况 D:未识别安装来源
|
||||
|
||||
- 保持保守策略:按现有回退方式展示已有更新项。
|
||||
- 这个分支仅用于防止源状态解析异常时整个列表为空。
|
||||
|
||||
## 前端交互
|
||||
|
||||
### 迁移确认弹窗
|
||||
|
||||
当用户选择的更新项中包含 `isMigration === true` 的记录时,继续弹出迁移确认框。
|
||||
|
||||
文案需要明确以下信息:
|
||||
|
||||
- 该应用将从传统 `aptss` 管理迁移到 `APM` 管理。
|
||||
- 迁移过程会先卸载现有 `aptss` 版本,再安装 `APM` 版本。
|
||||
- 迁移后,该应用后续更新将由 `APM` 管理。
|
||||
|
||||
### 下载队列表现
|
||||
|
||||
- 迁移任务加入下载队列时,名称与图标沿用更新中心项。
|
||||
- 队列项可继续显示为 `origin: "apm"`,因为最终安装目标是 `apm`。
|
||||
- 日志首条应明确表明这是迁移更新,而不是普通更新。
|
||||
|
||||
## 执行链路
|
||||
|
||||
### 当前问题
|
||||
|
||||
当前更新中心点击更新后,只是把任务交给现有下载/安装队列;迁移任务并不会真正先卸载 `aptss`。
|
||||
|
||||
### 新执行方式
|
||||
|
||||
对于 `isMigration === true` 的任务:
|
||||
|
||||
1. 创建更新任务并进入现有下载/安装队列。
|
||||
2. 在主进程的更新中心执行链路中识别该任务为迁移任务。
|
||||
3. 先调用:
|
||||
- `shell-caller.sh aptss remove <pkg>`
|
||||
4. 若卸载成功,再继续现有 `apm` 安装流程。
|
||||
5. 若卸载失败:
|
||||
- 不进入 `apm` 安装。
|
||||
- 将任务标记为失败。
|
||||
- 将错误信息推送到下载日志与更新中心状态。
|
||||
|
||||
### 失败处理
|
||||
|
||||
- `aptss remove` 失败:
|
||||
- 整个迁移任务失败。
|
||||
- 保留用户现有安装状态,不做后续安装。
|
||||
- `aptss remove` 成功但 `apm` 安装失败:
|
||||
- 任务失败。
|
||||
- 不做自动回滚。
|
||||
- 在日志中明确说明:旧版本已卸载,新版本安装失败,需要用户重试。
|
||||
|
||||
本次实现不加入自动回滚,避免在失败分支里引入额外高风险操作。
|
||||
|
||||
## 受影响模块
|
||||
|
||||
- `electron/main/backend/update-center/query.ts`
|
||||
- 重写合并规则。
|
||||
- `electron/main/backend/update-center/service.ts`
|
||||
- 保持迁移标记透传,并为后续执行提供足够字段。
|
||||
- `electron/main/backend/install-manager.ts` 或迁移任务真正进入的主进程安装执行层
|
||||
- 为迁移任务增加“先 aptss remove,再 apm install”的顺序执行。
|
||||
- `src/components/update-center/UpdateCenterMigrationConfirm.vue`
|
||||
- 更新提示文案。
|
||||
- `src/modules/updateCenter.ts`
|
||||
- 保持迁移项进入下载队列时的展示信息正确。
|
||||
|
||||
## 测试策略
|
||||
|
||||
需要新增或调整以下测试:
|
||||
|
||||
- `mergeUpdateSources()` 单元测试:
|
||||
- 仅 aptss 安装 + apm 更高版本 -> 仅返回一条迁移记录。
|
||||
- 仅 aptss 安装 + apm 不更高 -> 仅返回 aptss 记录。
|
||||
- 仅 apm 安装 + 双源同名更新 -> 仅返回 apm 记录。
|
||||
- 双方都安装 + 双源同名更新 -> 返回两条记录。
|
||||
- 更新中心服务/IPC 测试:
|
||||
- 迁移任务被正确标记并透传。
|
||||
- 安装执行测试:
|
||||
- 迁移任务先执行 `shell-caller.sh aptss remove <pkg>`。
|
||||
- 卸载失败时不会继续安装 `apm`。
|
||||
- 卸载成功后继续执行 `apm` 安装流程。
|
||||
- 前端测试:
|
||||
- 迁移弹窗文案与触发条件正确。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不实现迁移失败后的自动回滚。
|
||||
- 不修改普通 `aptss` 或普通 `apm` 更新的现有安装流程。
|
||||
- 不改变“双安装”场景下两条记录并存的行为。
|
||||
@@ -0,0 +1,196 @@
|
||||
# 更新中心 Spark 更新命令设计
|
||||
|
||||
## 背景
|
||||
|
||||
当前 Electron 更新中心对 `aptss` 来源的更新项仍保留一条旧路径:当任务没有本地下载文件时,直接执行 `shell-caller.sh aptss install -y <pkg> --only-upgrade`。这条路径会在宿主系统里直接升级软件包,但不会复用 Qt 更新器已经采用的“先下载 deb,再通过 `ssinstall` 安装”的流程。
|
||||
|
||||
仓库里已经存在一个更贴近更新器预期的行为参考:旧 Qt 更新器在安装 `aptss` 来源更新时,会对下载好的 deb 调用 `ssinstall`,并带上“不创建桌面快捷方式”和“安装后删除下载文件”等参数。
|
||||
|
||||
本次需求是:仅对 Electron 更新中心生效,把 Spark 软件包更新改为走 `shell-caller` 顶层 `ssinstall` 路径,同时避免更新时创建新的桌面项。
|
||||
|
||||
## 目标
|
||||
|
||||
1. Electron 更新中心处理 `aptss` 更新时,统一改为“下载 deb -> `shell-caller.sh ssinstall` 安装”。
|
||||
2. 更新时传入 `ssinstall` 的“不创建桌面项”参数,避免更新流程额外生成桌面快捷方式。
|
||||
3. 变更只作用于 Electron 更新中心,不影响普通安装流、APM 更新流和 `extras/shell-caller.sh` 的白名单行为。
|
||||
4. 继续沿用现有提权方式:若存在 `pkexec`,仍通过 `pkexec /opt/spark-store/extras/shell-caller.sh ...` 执行。
|
||||
|
||||
## 非目标
|
||||
|
||||
1. 不修改 `electron/main/backend/install-manager.ts` 的普通安装逻辑。
|
||||
2. 不修改 `apm` 来源更新的下载与安装方式。
|
||||
3. 不扩展 `extras/shell-caller.sh` 以支持新的 `aptss ssinstall` 子命令形式。
|
||||
4. 不修改旧 Qt 更新器行为;它只作为现有参考实现。
|
||||
|
||||
## 已确认的命令约束
|
||||
|
||||
### shell-caller 约束
|
||||
|
||||
当前仓库内的 `extras/shell-caller.sh` 只支持 3 个顶层命令类型:
|
||||
|
||||
1. `apm`
|
||||
2. `aptss`
|
||||
3. `ssinstall`
|
||||
|
||||
其中 `aptss` 仅允许 `install` 和 `remove` 两个子命令,不支持 `aptss ssinstall ...`。因此,本次实现不会尝试新增 `shell-caller aptss ssinstall` 这种调用形式,而是直接使用已存在的顶层 `ssinstall` 入口。
|
||||
|
||||
### ssinstall 参数名
|
||||
|
||||
本机 `ssinstall --help` 显示的真实参数名是:
|
||||
|
||||
```bash
|
||||
--no-create-desktop-entry
|
||||
```
|
||||
|
||||
因此,需求里口头表达的 `--no-create-desktop` 会在实现中落到 `--no-create-desktop-entry`,避免引入不存在的参数名。
|
||||
|
||||
## 现状问题
|
||||
|
||||
当前更新中心后端只有 APM 更新项会在刷新阶段补齐 `downloadUrl`、`fileName`、`size` 和 `sha512` 等下载元数据。`aptss` 更新项只来自 `apt list --upgradable` 的文本解析结果,因此:
|
||||
|
||||
1. `aptss` 更新项通常没有可下载 deb 的元数据。
|
||||
2. 没有 deb 文件时,安装逻辑会退回旧的 `aptss install --only-upgrade` 命令。
|
||||
3. 这使得 Electron 更新中心无法像 Qt 更新器那样稳定走 `ssinstall` 路径。
|
||||
|
||||
## 方案概览
|
||||
|
||||
采用“刷新阶段补齐 `aptss` 下载元数据,执行阶段统一走 `ssinstall`”的方案。
|
||||
|
||||
整体流程如下:
|
||||
|
||||
1. 刷新更新列表时,继续查询 `aptss` 的可升级包。
|
||||
2. 对每个 `aptss` 更新项额外查询 `apt download --print-uris` 元数据。
|
||||
3. 只有拿到 `downloadUrl` 和 `fileName` 的 `aptss` 更新项才进入最终更新列表。
|
||||
4. 执行更新任务时,先下载对应 deb。
|
||||
5. 下载完成后调用 `shell-caller.sh ssinstall <deb> --no-create-desktop-entry --delete-after-install`。
|
||||
6. 若存在提权命令,则实际执行 `pkexec /opt/spark-store/extras/shell-caller.sh ssinstall ...`。
|
||||
|
||||
这样可以让 Electron 更新中心的 `aptss` 更新行为与 Qt 更新器保持一致,同时严格限定在更新中心内部,不影响商店其他安装入口。
|
||||
|
||||
## 模块变更
|
||||
|
||||
### 1. `electron/main/backend/update-center/index.ts`
|
||||
|
||||
新增 `aptss` 下载元数据补全逻辑,方式与现有 APM 元数据补全保持一致。
|
||||
|
||||
建议变更:
|
||||
|
||||
1. 新增一个 `aptss` 的 `print-uris` 命令构造函数,复用当前 `apt-fast` 配置与源列表参数。
|
||||
2. 复用现有 `parsePrintUrisOutput()` 解析函数,不新增第二套解析器。
|
||||
3. 为 `aptss` 更新项新增与 APM 相同的元数据补全过程。
|
||||
4. 元数据查询失败的 `aptss` 项从最终可更新列表中剔除,并写入 warning。
|
||||
|
||||
这样做的原因是:更新中心一旦展示某个更新项,就应该能够实际完成下载和安装,而不是在任务执行阶段才发现缺少 deb 元数据。
|
||||
|
||||
### 2. `electron/main/backend/update-center/install.ts`
|
||||
|
||||
`aptss` 更新项的安装路径改为严格依赖已下载的 `filePath`。
|
||||
|
||||
行为调整:
|
||||
|
||||
1. `item.source === "aptss"` 且有 `filePath` 时,执行 `shell-caller.sh ssinstall`。
|
||||
2. 传参为:
|
||||
|
||||
```bash
|
||||
ssinstall <deb-path> --no-create-desktop-entry --delete-after-install
|
||||
```
|
||||
|
||||
3. 若存在 `superUserCmd`,则通过 `buildPrivilegedCommand()` 包装成:
|
||||
|
||||
```bash
|
||||
/usr/bin/pkexec /opt/spark-store/extras/shell-caller.sh ssinstall <deb-path> --no-create-desktop-entry --delete-after-install
|
||||
```
|
||||
|
||||
4. 删除 `aptss` 无文件时回退到 `buildLegacySparkUpgradeCommand()` 的行为。
|
||||
|
||||
这意味着 `aptss` 更新不再允许悄悄退回旧式 `aptss install --only-upgrade` 流程。
|
||||
|
||||
### 3. 其他模块
|
||||
|
||||
以下模块不应发生行为变化:
|
||||
|
||||
1. `electron/main/backend/install-manager.ts`
|
||||
2. `extras/shell-caller.sh`
|
||||
3. `spark-update-tool/` 中的 Qt 更新器逻辑
|
||||
4. `apm` 来源更新的下载与安装分支
|
||||
|
||||
## 数据流
|
||||
|
||||
### 刷新阶段
|
||||
|
||||
1. 读取 `aptss` 和 `apm` 的可升级列表。
|
||||
2. 读取已安装来源状态。
|
||||
3. 为 `aptss` 更新项加载 deb 元数据。
|
||||
4. 为 `apm` 更新项加载 deb 元数据。
|
||||
5. 合并来源、迁移标记、图标和其他展示字段。
|
||||
6. 返回只包含“可实际下载并安装”的更新项列表。
|
||||
|
||||
### 执行阶段
|
||||
|
||||
1. 任务进入 `downloading`。
|
||||
2. 使用已有 aria2 下载器下载 deb。
|
||||
3. 任务进入 `installing`。
|
||||
4. `aptss` 项执行 `shell-caller.sh ssinstall`。
|
||||
5. `apm` 项继续执行当前 `shell-caller.sh apm ssinstall` 流程。
|
||||
6. 成功后标记完成,失败则保留日志与错误信息。
|
||||
|
||||
## 错误处理
|
||||
|
||||
### 刷新失败
|
||||
|
||||
如果某个 `aptss` 包的元数据查询失败:
|
||||
|
||||
1. 不让该项进入可更新列表。
|
||||
2. 在 `warnings` 中记录具体失败信息,例如 `aptss metadata query for <pkg> failed ...`。
|
||||
3. 不影响其他更新项展示。
|
||||
|
||||
### 安装失败
|
||||
|
||||
如果 `shell-caller.sh ssinstall ...` 返回非 0:
|
||||
|
||||
1. 保持当前任务失败处理逻辑不变。
|
||||
2. 将 stdout/stderr 继续写入任务日志。
|
||||
3. 由任务队列把该更新项标记为 `failed`。
|
||||
|
||||
### 取消任务
|
||||
|
||||
取消逻辑保持不变。只要下载或安装子进程被中止,任务仍按当前机制进入 `cancelled` 或 `failed` 分支,不额外引入新的取消状态。
|
||||
|
||||
## 测试方案
|
||||
|
||||
### 单元测试
|
||||
|
||||
先写失败测试,再改实现。至少覆盖以下场景:
|
||||
|
||||
1. `load-items.test.ts`
|
||||
- `aptss` 更新项会额外查询 `print-uris` 元数据。
|
||||
- 元数据成功时,结果包含 `downloadUrl` 和 `fileName`。
|
||||
- 元数据失败时,该项被过滤并写入 warning。
|
||||
|
||||
2. `task-runner.test.ts`
|
||||
- `aptss` 文件安装走 `shell-caller.sh ssinstall <deb> --no-create-desktop-entry --delete-after-install`。
|
||||
- 不再断言旧的 `buildLegacySparkUpgradeCommand()` 输出。
|
||||
- `apm` 文件安装仍走 `shell-caller.sh apm ssinstall <deb>`,避免回归。
|
||||
|
||||
3. 如有必要,为安装构造函数补充更细粒度测试,确保带 `superUserCmd` 时参数顺序正确。
|
||||
|
||||
### 验证命令
|
||||
|
||||
实现完成后至少执行:
|
||||
|
||||
1. `npm run test -- --run src/__tests__/unit/update-center/load-items.test.ts src/__tests__/unit/update-center/task-runner.test.ts`
|
||||
2. `npm run lint`
|
||||
3. `npm run build`
|
||||
|
||||
## 风险与约束
|
||||
|
||||
1. `aptss` 元数据查询会为每个更新项新增一次命令调用,刷新成本会增加,但这是换取 updater-only `ssinstall` 行为所必需的最小代价。
|
||||
2. 若某些仓库源对 `apt download --print-uris` 返回格式异常,相关更新项会被过滤并显示 warning;这比静默退回旧命令更符合本次需求。
|
||||
3. `shell-caller.sh ssinstall` 会自动补上 `--native`,因此更新中心无需重复传入该参数。
|
||||
|
||||
## 决策总结
|
||||
|
||||
1. Electron 更新中心的 `aptss` 更新改为“下载 deb 后通过顶层 `shell-caller.sh ssinstall` 安装”。
|
||||
2. 实际使用的桌面项参数名为 `--no-create-desktop-entry`。
|
||||
3. 删除 `aptss` 更新回退到 `aptss install --only-upgrade` 的旧行为。
|
||||
4. 该变更只作用于 `electron/main/backend/update-center/`,不修改其他安装入口。
|
||||
@@ -0,0 +1,365 @@
|
||||
# Gitee Issue 巡检与 Opencode 启动设计
|
||||
|
||||
## 背景
|
||||
|
||||
当前仓库没有一个稳定的自动化流程,能够按固定周期检查 `https://gitee.com/spark-store-project/spark-store/issues`,筛出当前“最新且最重要”的 issue,并在人工确认后自动拉起新的 opencode 进程开始分析与修复。
|
||||
|
||||
你的目标不是让机器人直接静默修复,而是建立一个半自动流程:
|
||||
|
||||
1. 每 6 小时自动检查一次 Gitee issues。
|
||||
2. 自动筛出 1 个当前最值得处理的候选 issue。
|
||||
3. 默认只汇报,不自动开始修改。
|
||||
4. 你确认后,自动打开新的 opencode 窗口开始处理。
|
||||
5. 后续实际开始修改代码时,仍然以 `~/Desktop/spark-store` 作为基仓库,但必须通过 git worktree 从 `Erotica` 分支开出新分支,在隔离工作区中执行修改。
|
||||
|
||||
## 目标
|
||||
|
||||
1. 使用 `systemd --user` 定时器实现每 6 小时自动巡检。
|
||||
2. 每轮最多选择 1 个 issue 作为候选项。
|
||||
3. 候选项必须有可解释的评分结果,便于人工确认。
|
||||
4. 默认不自动修复,只记录候选状态并等待批准。
|
||||
5. 批准后自动启动新的 opencode 窗口,并把 issue 上下文传入。
|
||||
6. 为后续修复流程固定 worktree 约束:从 `Erotica` 分支开新分支,并保持 `~/Desktop/spark-store` 作为主仓库入口。
|
||||
7. 整个方案尽量独立于 Electron 主进程现有运行逻辑,避免把定时调度耦合进应用本体。
|
||||
|
||||
## 非目标
|
||||
|
||||
1. 不在本次实现中加入“自动修复后自动提交 PR”之类更长的链路。
|
||||
2. 不在本次实现中加入应用内 GUI 审批界面。
|
||||
3. 不在本次实现中实现复杂的 AI 优先级判断;优先使用透明、可维护的规则评分。
|
||||
4. 不在本次实现中把 issue 处理结果自动回写到 Gitee。
|
||||
5. 不在本次实现中实际创建 worktree 并改代码;这里只固定后续执行约束和启动提示。
|
||||
|
||||
## 方案选择
|
||||
|
||||
本次考虑三种方案:
|
||||
|
||||
1. 用户级 `systemd` 定时器 + 独立 Node/TypeScript 巡检脚本 + 本地批准入口。
|
||||
2. 用户级 `systemd` 定时器 + Gitee 评论驱动批准。
|
||||
3. 完全接入 Electron,使用应用内常驻进程和弹窗审批。
|
||||
|
||||
最终选择方案 1。
|
||||
|
||||
原因:
|
||||
|
||||
1. 它最小化对现有桌面应用逻辑的侵入,不要求应用常驻。
|
||||
2. `systemd --user` 已符合你的运行环境偏好,也与仓库里已有的用户级后台命令模式一致。
|
||||
3. 本地批准入口最容易落地,不依赖额外的 Gitee 写权限和 webhook/comment 解析。
|
||||
4. 后续如果要升级成评论审批或 GUI 审批,也可以在该方案基础上扩展。
|
||||
|
||||
## 设计概览
|
||||
|
||||
新增一个独立的 issue 巡检子系统,由五部分组成:
|
||||
|
||||
1. `check-issues` 巡检入口:抓取 issue、打分、落本地状态。
|
||||
2. `state` 状态层:保存当前候选项、历史批准记录和最近一次运行结果。
|
||||
3. `approve-issue` 批准入口:由你手动触发,读取当前候选项并进入启动流程。
|
||||
4. `opencode launcher`:负责拼接 issue prompt 并打开新的 opencode 窗口。
|
||||
5. `systemd --user` 单元:负责每 6 小时调度巡检入口。
|
||||
|
||||
整体数据流分为两个阶段:
|
||||
|
||||
1. 自动巡检阶段:仅发现和记录,不启动修复。
|
||||
2. 人工批准阶段:由你确认后,才启动新的 opencode 会话。
|
||||
|
||||
## 文件与模块边界
|
||||
|
||||
### 脚本入口
|
||||
|
||||
- 新增:`scripts/issue-bot/check-issues.ts`
|
||||
- 负责单次巡检执行。
|
||||
- 拉取 Gitee issues。
|
||||
- 调用评分逻辑选出候选项。
|
||||
- 写入状态文件和运行日志。
|
||||
|
||||
- 新增:`scripts/issue-bot/approve-issue.ts`
|
||||
- 负责读取当前候选项。
|
||||
- 检查是否已有未完成批准任务。
|
||||
- 标记当前 issue 为已批准。
|
||||
- 调用 opencode 启动器。
|
||||
|
||||
### 共享库
|
||||
|
||||
- 新增:`scripts/issue-bot/lib/gitee.ts`
|
||||
- 封装 issue 列表获取与基础字段归一化。
|
||||
- 输出统一结构,例如:`id`、`title`、`url`、`state`、`createdAt`、`updatedAt`、`labels`、`bodyPreview`。
|
||||
|
||||
- 新增:`scripts/issue-bot/lib/ranking.ts`
|
||||
- 根据“最新且最重要”的规则计算分数。
|
||||
- 输出总分和评分明细,便于人工解释。
|
||||
|
||||
- 新增:`scripts/issue-bot/lib/state.ts`
|
||||
- 负责本地状态读写。
|
||||
- 处理状态文件缺失、损坏、备份与迁移。
|
||||
|
||||
- 新增:`scripts/issue-bot/lib/opencode.ts`
|
||||
- 负责生成发给 opencode 的 prompt。
|
||||
- 负责调用本地 opencode 启动命令。
|
||||
- 固定写入 worktree 执行约束。
|
||||
|
||||
### 配置与调度
|
||||
|
||||
- 新增:`extras/systemd/spark-store-issue-bot.service`
|
||||
- 用户级一次性服务,执行单轮巡检。
|
||||
|
||||
- 新增:`extras/systemd/spark-store-issue-bot.timer`
|
||||
- 每 6 小时触发一次 service。
|
||||
|
||||
- 修改:`package.json`
|
||||
- 增加 `issue-bot:check`。
|
||||
- 增加 `issue-bot:approve`。
|
||||
|
||||
## 本地状态模型
|
||||
|
||||
建议把状态文件写到用户目录下的缓存位置,而不是仓库内,避免污染工作区。
|
||||
|
||||
建议路径:`~/.cache/spark-store/issue-bot/state.json`
|
||||
|
||||
状态至少包含:
|
||||
|
||||
```ts
|
||||
interface IssueBotState {
|
||||
currentCandidate: RankedIssue | null;
|
||||
approvedIssue: ApprovedIssue | null;
|
||||
seenIssueIds: number[];
|
||||
lastRunAt: string | null;
|
||||
lastRunStatus: "idle" | "success" | "network-error" | "parse-error";
|
||||
lastRunMessage: string | null;
|
||||
}
|
||||
```
|
||||
|
||||
其中:
|
||||
|
||||
1. `currentCandidate` 表示当前等待你批准的候选 issue。
|
||||
2. `approvedIssue` 表示已经批准并已启动 opencode 的 issue,用于避免重复批准。
|
||||
3. `seenIssueIds` 用于辅助去重,避免每轮都反复选择同一批低质量 issue。
|
||||
4. `lastRun*` 用于排查巡检失败原因。
|
||||
|
||||
## Gitee 拉取策略
|
||||
|
||||
优先顺序如下:
|
||||
|
||||
1. 若存在可稳定使用的 Gitee API,则优先使用 API。
|
||||
2. 若 API 受限或字段不足,则退回页面抓取。
|
||||
|
||||
无论采用哪种来源,`gitee.ts` 对外只暴露统一的 issue 数据结构,不把 HTML 解析细节传播到评分层和状态层。
|
||||
|
||||
抓取范围只包含:
|
||||
|
||||
1. 打开的 issue。
|
||||
2. 当前仓库 `spark-store-project/spark-store`。
|
||||
3. 必需字段能提取成功的 issue。
|
||||
|
||||
如果本轮无法获取完整 issue 列表:
|
||||
|
||||
1. 记录错误。
|
||||
2. 不覆盖现有 `currentCandidate`。
|
||||
3. 结束本轮执行,等待下次 timer。
|
||||
|
||||
## 排序与筛选规则
|
||||
|
||||
评分逻辑使用可解释的静态规则,不做黑盒决策。
|
||||
|
||||
### 基础过滤
|
||||
|
||||
先过滤掉以下 issue:
|
||||
|
||||
1. 已关闭 issue。
|
||||
2. 已批准且尚未被显式清理的 issue。
|
||||
3. 缺少标题或链接等关键字段的异常项。
|
||||
|
||||
### 加分项
|
||||
|
||||
以下情况加分:
|
||||
|
||||
1. 标题或内容包含高影响关键词:`崩溃`、`打不开`、`无法安装`、`升级失败`、`卡死`、`白屏`、`闪退`。
|
||||
2. 与主流程强相关:安装、卸载、更新、启动、搜索、列表加载。
|
||||
3. 最近创建或最近更新。
|
||||
4. 含有复现步骤、日志、截图、错误信息。
|
||||
5. 带有明显 bug 类型标签。
|
||||
|
||||
### 减分项
|
||||
|
||||
以下情况减分:
|
||||
|
||||
1. 纯咨询类或需求讨论类 issue。
|
||||
2. 信息过少,例如只有一句“不能用”。
|
||||
3. 明显重复、无明确可执行内容。
|
||||
|
||||
### 产出格式
|
||||
|
||||
`ranking.ts` 输出不只包含总分,还包含明细,例如:
|
||||
|
||||
```ts
|
||||
interface RankingBreakdown {
|
||||
total: number;
|
||||
reasons: string[];
|
||||
}
|
||||
```
|
||||
|
||||
状态文件和批准前摘要都需要携带这些明细,确保“为什么选它”是透明的。
|
||||
|
||||
## 巡检流程
|
||||
|
||||
`check-issues.ts` 的单轮行为固定为:
|
||||
|
||||
1. 读取本地状态。
|
||||
2. 拉取 Gitee issue 列表。
|
||||
3. 标准化数据。
|
||||
4. 按过滤规则剔除不可处理项。
|
||||
5. 计算每个 issue 的分数。
|
||||
6. 选出得分最高的 1 个 issue。
|
||||
7. 将其写入 `currentCandidate`。
|
||||
8. 更新 `lastRunAt`、`lastRunStatus` 和摘要信息。
|
||||
|
||||
如果没有候选项:
|
||||
|
||||
1. 将 `currentCandidate` 设为 `null`。
|
||||
2. 写入“本轮无可处理 issue”的状态。
|
||||
3. 不触发任何后续动作。
|
||||
|
||||
## 批准流程
|
||||
|
||||
`approve-issue.ts` 的行为固定为:
|
||||
|
||||
1. 读取本地状态。
|
||||
2. 检查 `currentCandidate` 是否存在。
|
||||
3. 检查是否已有 `approvedIssue` 正在等待处理结果。
|
||||
4. 若可批准,则将候选项复制到 `approvedIssue`。
|
||||
5. 调用 opencode 启动器。
|
||||
6. 启动成功后保留 `approvedIssue`,并可选择清空 `currentCandidate`。
|
||||
|
||||
本次实现采用保守策略:
|
||||
|
||||
1. 启动成功后,清空 `currentCandidate`。
|
||||
2. 保留 `approvedIssue`,避免同一 issue 被重复批准。
|
||||
|
||||
后续如果需要“已完成”或“已放弃”清理动作,可以再补一个独立命令。
|
||||
|
||||
## Opencode 启动器设计
|
||||
|
||||
`opencode.ts` 负责两件事:
|
||||
|
||||
1. 生成 prompt。
|
||||
2. 调用本地 opencode 启动命令。
|
||||
|
||||
### Prompt 内容
|
||||
|
||||
prompt 需要至少包含:
|
||||
|
||||
1. issue 标题。
|
||||
2. issue URL。
|
||||
3. issue 摘要。
|
||||
4. 评分原因。
|
||||
5. 任务目标:分析根因并开始修复。
|
||||
6. 明确约束:开始修改时,基仓库使用 `~/Desktop/spark-store`,但实际编码必须通过 git worktree,从 `Erotica` 分支开出新分支后进行。
|
||||
|
||||
### Worktree 约束
|
||||
|
||||
批准后启动的新 opencode 会话中,必须显式看到以下执行约束:
|
||||
|
||||
1. 基仓库固定为 `~/Desktop/spark-store`。
|
||||
2. 真正开始修改代码前,使用 git worktree 创建隔离工作区。
|
||||
3. 新 worktree 必须从 `Erotica` 分支开出新的工作分支。
|
||||
4. 修复工作在该 worktree 中进行,而不是直接在主仓库工作目录中进行。
|
||||
|
||||
这里的职责是“把约束传给后续修复会话”,而不是在当前巡检脚本里代替用户创建 worktree。
|
||||
|
||||
### 启动命令配置
|
||||
|
||||
不要把 opencode 启动命令硬编码成不可修改的固定路径。
|
||||
|
||||
推荐顺序:
|
||||
|
||||
1. 读取环境变量,例如 `SPARK_STORE_OPENCODE_CMD`。
|
||||
2. 若未配置,则退回默认命令模板。
|
||||
3. 若命令不存在,返回明确错误并保留 `currentCandidate`/`approvedIssue` 状态供重试。
|
||||
|
||||
## systemd 调度设计
|
||||
|
||||
使用用户级 systemd 单元:
|
||||
|
||||
### `spark-store-issue-bot.service`
|
||||
|
||||
职责:
|
||||
|
||||
1. 调用一次 `issue-bot:check`。
|
||||
2. 以 oneshot 形式运行。
|
||||
3. 将日志交给 systemd journal。
|
||||
|
||||
### `spark-store-issue-bot.timer`
|
||||
|
||||
职责:
|
||||
|
||||
1. 每 6 小时触发一次 service。
|
||||
2. 启用持久化调度,使设备休眠后恢复时仍可补跑。
|
||||
|
||||
不把批准动作放进 timer,因为批准必须由人工触发。
|
||||
|
||||
## 错误处理
|
||||
|
||||
### 网络或解析失败
|
||||
|
||||
1. 记录 `lastRunStatus` 为失败类型。
|
||||
2. 保留旧候选项,不清空有效状态。
|
||||
3. 输出清晰日志,供 `journalctl --user` 排查。
|
||||
|
||||
### 状态文件损坏
|
||||
|
||||
1. 读取失败时先备份原文件。
|
||||
2. 生成新的空状态。
|
||||
3. 在日志中注明发生了状态恢复。
|
||||
|
||||
### 启动 opencode 失败
|
||||
|
||||
1. 不丢失候选 issue 信息。
|
||||
2. 记录失败信息到状态文件。
|
||||
3. 允许你修正环境后再次执行批准或重试命令。
|
||||
|
||||
## 测试与验证
|
||||
|
||||
### 脚本层验证
|
||||
|
||||
需要至少覆盖以下行为:
|
||||
|
||||
1. 有多个 issue 时,能按规则稳定选出得分最高的候选项。
|
||||
2. 无 issue 或全被过滤时,`currentCandidate` 正确为空。
|
||||
3. 状态文件缺失时能初始化默认状态。
|
||||
4. 状态文件损坏时能备份并恢复。
|
||||
5. 批准入口能读取候选项并更新状态。
|
||||
6. opencode 启动命令缺失时,能返回明确错误而不丢状态。
|
||||
|
||||
### 手动验证
|
||||
|
||||
需要人工验证:
|
||||
|
||||
1. `npm run issue-bot:check` 能成功写出候选项。
|
||||
2. 连续运行两次巡检,状态更新符合预期,没有异常重复。
|
||||
3. `npm run issue-bot:approve` 能基于当前候选项启动新的 opencode 窗口。
|
||||
4. 启动后的 prompt 中包含 worktree 约束和 `Erotica` 分支要求。
|
||||
5. `systemctl --user start spark-store-issue-bot.service` 可执行。
|
||||
6. `systemctl --user enable --now spark-store-issue-bot.timer` 后能看到 timer 生效。
|
||||
|
||||
### 仓库质量验证
|
||||
|
||||
完成实现后,至少执行:
|
||||
|
||||
1. `npm run lint`
|
||||
2. `npm run build:vite`
|
||||
|
||||
如果脚本新增了独立测试,还要运行相应测试命令。
|
||||
|
||||
## 风险与约束
|
||||
|
||||
1. Gitee 页面结构可能变化,因此 `gitee.ts` 需要把抓取逻辑局部化,避免影响其他模块。
|
||||
2. “最重要”本质上是启发式规则,不保证绝对正确,因此必须保留人工批准环节。
|
||||
3. 如果 opencode 的命令行接口或窗口启动方式在本机环境中变化,需要通过配置而不是源码硬编码来适配。
|
||||
4. worktree 约束属于后续修复会话的执行要求,当前设计只负责传达和固化,不负责提前改变用户当前工作区。
|
||||
|
||||
## 决策总结
|
||||
|
||||
1. 用 `systemd --user` 定时器每 6 小时巡检一次 Gitee issues。
|
||||
2. 每轮只选 1 个“最新且最重要”的候选 issue。
|
||||
3. 默认只汇报,不自动修复。
|
||||
4. 你批准后,再自动拉起新的 opencode 窗口。
|
||||
5. 启动 prompt 中必须固定写明:后续开始修改时,以 `~/Desktop/spark-store` 为基仓库,并通过 git worktree 从 `Erotica` 分支开新分支后执行修复。
|
||||
@@ -0,0 +1,276 @@
|
||||
# 已安装应用管理与更新中心加载态设计
|
||||
|
||||
## 背景
|
||||
|
||||
当前仓库里有三个直接影响体验的问题:
|
||||
|
||||
1. 更新中心调用 `updateCenterStore.open()` 时,会先等待主进程返回快照,再决定是否展示模态框。用户在数据返回前看不到任何反馈,主观感受就是“打开很慢”。
|
||||
2. 软件管理里 `spark` 来源当前直接读取 `dpkg-query -W` 的全量安装包,结果混入了大量没有桌面入口的系统包,与“软件管理”应管理可见应用的预期不符。
|
||||
3. 软件管理弹窗目前只有“卸载”操作,没有“打开”操作;同时 `src/App.vue` 对 `spark` 来源还有一条“若不在远端商店目录中则直接跳过”的过滤,会导致本机已有桌面应用即使后端已发现,也不会展示出来。
|
||||
|
||||
本次设计的目标是用最小改动修复这三个问题,不重做更新中心和软件管理的整体结构。
|
||||
|
||||
## 目标
|
||||
|
||||
1. 更新中心在用户触发打开时立即显示模态框,并展示明确的加载反馈。
|
||||
2. `spark` 软件管理改为基于 `/usr/share/applications` 的桌面应用扫描,而不是全量系统包扫描。
|
||||
3. `spark` 桌面应用通过 `realpath` 后的 desktop 文件路径,结合 `dpkg -S <desktop-path>` 反查所属包名。
|
||||
4. `apm` 软件管理保持现有 `apm list --installed` 语义,继续展示依赖项。
|
||||
5. 软件管理弹窗中的已安装项支持直接打开软件,复用当前已有的应用启动 IPC,而不是新增一套启动协议。
|
||||
|
||||
## 非目标
|
||||
|
||||
1. 不重构更新中心的主进程数据加载流程。
|
||||
2. 不把软件管理改成“每个 desktop 入口一条记录”;本次仍按“每个包一条记录”展示。
|
||||
3. 不改变 `apm` 来源中依赖项继续显示的现有产品决定。
|
||||
4. 不新增应用启动器脚本,也不修改 `launch-app` IPC 的入参与调用协议。
|
||||
5. 不把软件管理改造成新的独立模块或完整应用索引子系统。
|
||||
|
||||
## 方案概览
|
||||
|
||||
本次改动拆成三条最小链路:
|
||||
|
||||
1. 更新中心在渲染层增加独立加载态,让模态框先出现,再等待主进程快照。
|
||||
2. `list-installed("spark")` 改为扫描 `/usr/share/applications` 并反查包名,再补齐版本、架构与图标信息。
|
||||
3. 已安装应用弹窗增加“打开”按钮,并移除 `spark` 来源依赖远端商店目录的前端过滤,让本机已发现的桌面应用能够真正显示与启动。
|
||||
|
||||
## 更新中心加载态
|
||||
|
||||
### 当前问题
|
||||
|
||||
`src/App.vue` 中的 `openUpdateModal()` 直接 `await updateCenterStore.open()`,而 `src/modules/updateCenter.ts` 的 `open()` 会在拿到完整快照后才把 `isOpen` 设为 `true`。因此用户点击后会先经历一段无反馈等待。
|
||||
|
||||
### 目标行为
|
||||
|
||||
1. 用户触发打开更新中心时,模态框立即出现。
|
||||
2. 数据尚未返回时,模态框主体显示“正在检查更新”的加载态,而不是空白区域。
|
||||
3. 首次打开完成后,正常展示更新列表或错误提示。
|
||||
4. 用户在已打开的更新中心里点击“刷新”时,继续使用同一加载状态字段,并禁用刷新按钮,避免重复触发。
|
||||
|
||||
### 设计
|
||||
|
||||
在 `src/modules/updateCenter.ts` 中为 `UpdateCenterStore` 新增渲染层加载状态,例如 `loading: Ref<boolean>`。
|
||||
|
||||
行为规则:
|
||||
|
||||
1. `open()` 调用开始时:
|
||||
- 先重置本次会话状态;
|
||||
- 立即设置 `isOpen.value = true`;
|
||||
- 设置 `loading.value = true`;
|
||||
- 然后再等待 `window.updateCenter.open()`。
|
||||
2. `open()` 成功或失败结束时:
|
||||
- 统一将 `loading.value = false`。
|
||||
3. `refresh()` 开始时:
|
||||
- 设置 `loading.value = true`;
|
||||
- 调用 `window.updateCenter.refresh()`;
|
||||
- 完成后再恢复 `loading.value = false`。
|
||||
4. `closeNow()` 时:
|
||||
- 关闭模态框;
|
||||
- 清理搜索、选中项与迁移确认状态;
|
||||
- 同时清理渲染层加载态,避免下次打开继承旧状态。
|
||||
|
||||
### UI 呈现
|
||||
|
||||
`src/components/UpdateCenterModal.vue` 负责根据 `store.loading.value` 切换内容:
|
||||
|
||||
1. 当 `loading === true` 且还没有可展示项时,列表区域显示居中的加载卡片或 spinner,文案为“正在检查更新…”。
|
||||
2. 当 `loading === true` 且已有旧列表时,保留当前列表内容,同时在顶部或列表区域显示轻量的“正在刷新…”提示,避免刷新时内容闪烁清空。
|
||||
3. `src/components/update-center/UpdateCenterToolbar.vue` 中的刷新按钮在 `loading === true` 时禁用,并可复用现有刷新图标做旋转或弱化处理。
|
||||
|
||||
这个方案只在渲染层加状态,不改主进程 `update-center-open` / `update-center-refresh` 的 IPC 协议,因此不会影响现有更新中心服务与测试边界。
|
||||
|
||||
## `spark` 软件管理的桌面应用扫描规则
|
||||
|
||||
### 当前问题
|
||||
|
||||
`electron/main/backend/install-manager.ts` 中 `list-installed("spark")` 目前直接跑:
|
||||
|
||||
```bash
|
||||
dpkg-query -W -f=${Package} ${Version} ${Architecture}\n
|
||||
```
|
||||
|
||||
它得到的是全量系统包,而不是用户可管理的桌面软件。
|
||||
|
||||
### 目标行为
|
||||
|
||||
`spark` 来源的软件管理只显示 `/usr/share/applications` 下可映射到系统包的桌面应用,每个包只展示一个条目。
|
||||
|
||||
### 扫描算法
|
||||
|
||||
主进程对 `spark` 来源执行以下流程:
|
||||
|
||||
1. 枚举 `/usr/share/applications` 目录中的 `.desktop` 文件。
|
||||
2. 对每个候选文件执行 `realpath`,得到实际 desktop 路径,兼容软链接场景。
|
||||
3. 读取 desktop 内容,解析:
|
||||
- `Name`
|
||||
- `Icon`
|
||||
- `NoDisplay`
|
||||
4. 过滤规则:
|
||||
- 不是 `.desktop` 的文件直接跳过;
|
||||
- `NoDisplay=true` 的 desktop 跳过;
|
||||
- 无法读取、无法解析或 `realpath` 失败的条目跳过;
|
||||
- `dpkg -S <realpath后的desktop路径>` 无法定位所属包名的条目跳过。
|
||||
5. 对通过过滤的条目调用 `dpkg -S <desktop-path>` 反查所属包。
|
||||
6. 将 desktop 条目按包名去重:
|
||||
- 同一包命中多个有效 desktop 时,仅保留第一个有效条目;
|
||||
- “第一个”的定义以稳定排序后的 desktop 文件名遍历顺序为准,保证结果可预测。
|
||||
7. 收集到包名后,再补齐版本和架构信息,形成最终 `InstalledAppInfo[]`。
|
||||
|
||||
### 包信息补齐
|
||||
|
||||
为了保留当前软件管理卡片里的版本与架构展示,`spark` 来源仍需要版本与架构信息,但不再以它作为筛选源。
|
||||
|
||||
推荐做法:
|
||||
|
||||
1. 先通过 desktop 扫描得到有效包名集合。
|
||||
2. 再执行一次 `dpkg-query -W -f=${Package}\t${Version}\t${Architecture}\n` 构建元数据映射。
|
||||
3. 仅为扫描结果中出现的包补齐 `version` 和 `arch`。
|
||||
|
||||
这样保留了现有 UI 所需字段,同时避免再次回到“全量包即软件管理内容”的旧行为。
|
||||
|
||||
### 图标与名称
|
||||
|
||||
对于 `spark` 来源:
|
||||
|
||||
1. `name` 优先使用 desktop 的 `Name=`。
|
||||
2. `icon` 优先使用 desktop 的 `Icon=`;若图标字段是绝对路径,则延续现有 `file://` 使用方式;若是图标名,则允许继续走当前前端回退策略或显示默认占位。
|
||||
3. `pkgname` 以 `dpkg -S` 反查出的包名为准,而不是 desktop 文件名。
|
||||
|
||||
### 错误处理
|
||||
|
||||
桌面应用扫描必须按“单项失败不拖垮整体列表”处理:
|
||||
|
||||
1. 某个 desktop 读取失败,只跳过该项。
|
||||
2. 某个 desktop 无法反查包名,只跳过该项。
|
||||
3. 只有当整个目录无法读取、或关键命令整体失败时,才返回 `success: false` 给渲染层。
|
||||
|
||||
## `apm` 软件管理保持现状
|
||||
|
||||
`apm` 来源继续使用当前 `apm list --installed` 结果,行为保持不变:
|
||||
|
||||
1. 仍保留依赖项展示。
|
||||
2. 仍使用现有的 APM `entries/applications` 解析名称、图标与是否为依赖项。
|
||||
3. 不把 `apm` 来源改成纯 desktop 视角。
|
||||
|
||||
这样可以满足“apm 包含依赖”的明确要求,同时把本次修改范围限制在 `spark` 侧软件识别逻辑。
|
||||
|
||||
## 渲染层已安装应用列表修正
|
||||
|
||||
### 当前问题
|
||||
|
||||
`src/App.vue` 中 `refreshInstalledApps()` 当前有一条 `spark` 特有过滤:
|
||||
|
||||
1. 先在远端商店应用列表 `apps.value` 中寻找同名应用;
|
||||
2. 如果 `origin === "spark" && !appInfo`,则直接 `continue`。
|
||||
|
||||
这会让许多本机桌面应用即使被主进程发现,也不会显示在软件管理中。
|
||||
|
||||
### 新规则
|
||||
|
||||
1. `refreshInstalledApps()` 对 `spark` 与 `apm` 统一采用“远端有完整信息则复用,远端没有则构造最小 App 对象”的策略。
|
||||
2. 删除 `spark` 来源的“找不到远端目录就跳过”逻辑。
|
||||
3. 这样主进程发现的本机桌面应用,无论是否存在于远端商店分类 JSON 中,都能在软件管理中展示出来。
|
||||
|
||||
### 最小 App 对象
|
||||
|
||||
当远端列表中找不到对应应用时,继续构造最小 `App` 对象,并补齐以下关键字段:
|
||||
|
||||
1. `name`
|
||||
2. `pkgname`
|
||||
3. `version`
|
||||
4. `origin`
|
||||
5. `currentStatus: "installed"`
|
||||
6. `arch`
|
||||
7. `flags`
|
||||
8. `isDependency`
|
||||
9. `icons`(如主进程提供)
|
||||
|
||||
其他目录型字段继续使用当前最小占位值即可,不额外扩展模型。
|
||||
|
||||
## 软件管理“打开软件”交互
|
||||
|
||||
### 目标行为
|
||||
|
||||
已安装应用弹窗中的每一项都支持直接打开软件,且不影响现有“卸载”入口。
|
||||
|
||||
### 交互设计
|
||||
|
||||
`src/components/InstalledAppsModal.vue` 中每个应用项新增一个 `打开` 按钮:
|
||||
|
||||
1. 点击“打开”时向父组件发出 `open-app` 事件,并透传:
|
||||
- `pkgname`
|
||||
- `origin`
|
||||
2. “卸载”按钮保留。
|
||||
3. 对于没有可启动信息的项,不新增额外灰态逻辑,因为本次两侧都沿用包名启动;只要条目被纳入软件管理,就认为可以尝试启动。
|
||||
|
||||
### 启动链路
|
||||
|
||||
继续复用当前已有 IPC:`launch-app`
|
||||
|
||||
1. `spark` 来源继续执行:
|
||||
- `/opt/spark-store/extras/app-launcher start <pkgname>`
|
||||
2. `apm` 来源继续执行:
|
||||
- `apm launch <pkgname>`
|
||||
|
||||
这个 IPC 已被下载详情与应用详情页复用,因此本次不改协议,只把软件管理接入同一入口。
|
||||
|
||||
## 模块影响范围
|
||||
|
||||
### 主进程
|
||||
|
||||
1. `electron/main/backend/install-manager.ts`
|
||||
- 调整 `list-installed("spark")` 的发现逻辑。
|
||||
- 可按需要抽出一个小型 helper 处理 spark desktop 扫描,避免继续堆大单文件。
|
||||
|
||||
### 渲染层状态与页面
|
||||
|
||||
1. `src/modules/updateCenter.ts`
|
||||
- 新增加载态,并调整 `open()` / `refresh()` / `closeNow()` 的时序。
|
||||
2. `src/components/UpdateCenterModal.vue`
|
||||
- 根据加载态展示“正在检查更新”或“正在刷新”提示。
|
||||
3. `src/components/update-center/UpdateCenterToolbar.vue`
|
||||
- 刷新按钮支持禁用与加载视觉状态。
|
||||
4. `src/components/InstalledAppsModal.vue`
|
||||
- 新增“打开”按钮与 `open-app` 事件。
|
||||
5. `src/App.vue`
|
||||
- 打开更新中心时不再等待模态框延迟出现。
|
||||
- 修正 `spark` 来源软件列表的远端目录过滤。
|
||||
- 将软件管理中的 `open-app` 事件接到现有 `openDownloadedApp()`。
|
||||
|
||||
## 测试策略
|
||||
|
||||
### 更新中心
|
||||
|
||||
扩展以下测试:
|
||||
|
||||
1. `src/__tests__/unit/update-center/store.test.ts`
|
||||
- 覆盖 `open()` 在等待快照期间就已将 `isOpen` 置为 `true`。
|
||||
- 覆盖 `loading` 在 `open()` 与 `refresh()` 生命周期中的变化。
|
||||
2. `src/__tests__/unit/update-center/UpdateCenterModal.test.ts`
|
||||
- 覆盖加载态文案展示。
|
||||
- 覆盖刷新按钮在加载时被禁用。
|
||||
|
||||
### 软件管理
|
||||
|
||||
1. 为 `spark` desktop 扫描逻辑新增单元测试,覆盖:
|
||||
- 从 `/usr/share/applications` 发现有效 desktop;
|
||||
- 通过 `realpath + dpkg -S` 反查包名;
|
||||
- 跳过 `NoDisplay=true`;
|
||||
- 同包多个 desktop 仅保留一个;
|
||||
- 单个 desktop 失败不会让整批结果失败。
|
||||
2. 扩展 `src/__tests__/unit/InstalledAppsModal.test.ts`
|
||||
- 覆盖“打开”按钮可见;
|
||||
- 覆盖点击后会发出 `open-app` 事件。
|
||||
|
||||
### 回归验证
|
||||
|
||||
1. `spark` 来源软件管理仍可卸载。
|
||||
2. `apm` 来源软件管理仍保留依赖项显示。
|
||||
3. 下载详情与应用详情页已有的 `launch-app` 调用不受影响。
|
||||
|
||||
## 风险与约束
|
||||
|
||||
1. `dpkg -S` 输出格式可能包含架构后缀或多条匹配结果,解析时需要明确采用“第一条所有权记录”的稳定策略,并只提取包名部分。
|
||||
2. 某些 desktop 图标可能是主题图标名而非绝对路径;本次不重做图标解析,只保证名称与路径被正确透传。
|
||||
3. 如果某些本机桌面应用没有远端商店元数据,软件管理中会显示最小信息卡片;这是预期结果,因为需求本身就是“以本机 `/usr/share/applications` 为准”。
|
||||
4. 更新中心加载态只解决“无反馈等待”的问题,不保证主进程真实查询耗时本身缩短。
|
||||
@@ -0,0 +1,89 @@
|
||||
# Installed Apps Modal Actions Design
|
||||
|
||||
## Background
|
||||
|
||||
The installed-apps modal currently renders each installed app row with display information and an uninstall button only. It no longer exposes any path to launch an installed app or open that app's detail modal.
|
||||
|
||||
As a result:
|
||||
|
||||
1. Users cannot launch apps from the installed-apps manager.
|
||||
2. Clicking apps that are already listed in the store no longer opens their detail view from that manager.
|
||||
|
||||
The parent app already has working handlers for both behaviors:
|
||||
|
||||
- `openDownloadedApp(pkgname, origin)` for launching
|
||||
- `openDetail(app)` for showing app details
|
||||
|
||||
The regression is therefore in the modal interaction layer rather than in the launch backend itself.
|
||||
|
||||
## Goals
|
||||
|
||||
1. Restore a direct “open app” action in the installed-apps modal.
|
||||
2. Restore a “view details” action for installed apps that can be matched to store detail data.
|
||||
3. Reuse the existing parent handlers instead of creating a second launch/detail path.
|
||||
4. Keep uninstall behavior unchanged.
|
||||
5. Keep the change local to the installed-apps modal and its parent wiring.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
1. Do not redesign the whole installed-apps UI.
|
||||
2. Do not change uninstall flow.
|
||||
3. Do not add a brand new launcher backend.
|
||||
4. Do not change app-detail modal behavior itself.
|
||||
|
||||
## Recommended Approach
|
||||
|
||||
Add two explicit actions to each installed-app row:
|
||||
|
||||
1. `打开` - always available for installed apps, routed to the existing launch handler.
|
||||
2. `查看详情` - available only when the app has enough store metadata to open a meaningful detail modal.
|
||||
|
||||
The modal emits these actions upward, and `App.vue` wires them to the existing parent methods. This restores behavior with minimal code movement and avoids duplicating launch or detail logic.
|
||||
|
||||
## UI Behavior
|
||||
|
||||
### Open action
|
||||
|
||||
- Every installed app row gets an `打开` button.
|
||||
- Clicking it emits the installed app object upward.
|
||||
- The parent maps this to `openDownloadedApp(app.pkgname, app.origin)`.
|
||||
|
||||
### Detail action
|
||||
|
||||
- Installed apps that can be resolved to a store-backed detail view get a `查看详情` button.
|
||||
- The modal should treat an app as detail-capable when its data is sufficient for the existing `openDetail` path, specifically when:
|
||||
- it has a non-`unknown` category, or
|
||||
- it already carries enough store-backed fields to be opened meaningfully by the current parent logic.
|
||||
- Clicking it emits the app upward.
|
||||
- The parent maps this to `openDetail(app)`.
|
||||
|
||||
### Uninstall action
|
||||
|
||||
- The existing `卸载` button remains unchanged.
|
||||
|
||||
## Event Contract
|
||||
|
||||
`InstalledAppsModal.vue` should expose two additional emits:
|
||||
|
||||
1. `open-app`
|
||||
2. `open-detail`
|
||||
|
||||
`App.vue` should listen to both and route them to existing functions, not wrappers with new behavior.
|
||||
|
||||
## Data Flow
|
||||
|
||||
1. `refreshInstalledApps()` continues building the installed app list.
|
||||
2. Each installed app row decides whether the detail action is available.
|
||||
3. Modal emits the chosen action with the clicked app.
|
||||
4. Parent receives the event and invokes the existing launch/detail flow.
|
||||
|
||||
## Testing
|
||||
|
||||
Add focused unit coverage for the modal:
|
||||
|
||||
1. It renders the `打开` button for installed items.
|
||||
2. It renders the `查看详情` button only when the app is detail-capable.
|
||||
3. It emits `open-app` when the open button is clicked.
|
||||
4. It emits `open-detail` when the detail button is clicked.
|
||||
|
||||
The tests do not need to re-test the internals of `openDownloadedApp()` or `openDetail()`; they only need to prove the modal restores the event path correctly.
|
||||
@@ -0,0 +1,91 @@
|
||||
# Update Center No-APTSS Behavior Design
|
||||
|
||||
## Background
|
||||
|
||||
The Electron update center currently loads Spark (`aptss`) and APM updates together inside `electron/main/backend/update-center/index.ts`. The loader unconditionally runs Spark-side commands and Spark metadata enrichment, even on systems where `aptss` is not installed.
|
||||
|
||||
In that environment, the update center should not continue the Spark update path and surface command failures. Instead, Spark updates should be skipped cleanly while the APM path continues to work.
|
||||
|
||||
## Goals
|
||||
|
||||
1. When `aptss` is unavailable, the update center must not keep executing Spark update queries.
|
||||
2. When `aptss` is unavailable but APM is available, the update center should still open and show APM updates.
|
||||
3. Spark metadata loading must also be skipped when `aptss` is unavailable.
|
||||
4. Missing `aptss` should not be surfaced as a fatal update-center error by itself.
|
||||
5. Existing behavior should remain unchanged on systems where `aptss` is available.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
1. Do not redesign the update-center service or UI.
|
||||
2. Do not change notifier behavior in this task.
|
||||
3. Do not change how APM updates are loaded.
|
||||
4. Do not add a new settings toggle or user-facing prompt.
|
||||
|
||||
## Recommended Approach
|
||||
|
||||
Add a lightweight backend availability gate for the Spark branch at the start of `loadUpdateCenterItems()`.
|
||||
|
||||
If `aptss` is unavailable, treat the Spark source as absent rather than failed:
|
||||
|
||||
1. Skip the Spark upgradable query.
|
||||
2. Skip the Spark installed-package query.
|
||||
3. Skip Spark metadata enrichment.
|
||||
4. Continue loading APM items normally.
|
||||
|
||||
This keeps the change local to the update-center backend and avoids reporting a missing Spark source as an error when the APM source can still provide valid updates.
|
||||
|
||||
## Data Flow Changes
|
||||
|
||||
### Current behavior
|
||||
|
||||
`loadUpdateCenterItems()` currently runs these in parallel:
|
||||
|
||||
1. Spark upgradable query
|
||||
2. APM upgradable query
|
||||
3. Spark installed query
|
||||
4. APM installed query
|
||||
|
||||
Then it always attempts category/icon/metadata enrichment for both source lists.
|
||||
|
||||
### New behavior
|
||||
|
||||
Before starting source queries, check whether `aptss` exists in `PATH`.
|
||||
|
||||
If available:
|
||||
|
||||
- Keep the existing Spark path unchanged.
|
||||
|
||||
If unavailable:
|
||||
|
||||
- Set Spark upgradable result to an empty successful result.
|
||||
- Set Spark installed result to an empty successful result.
|
||||
- Skip Spark metadata enrichment by passing an empty Spark item list forward.
|
||||
|
||||
APM loading remains unchanged in both cases.
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Missing `aptss`
|
||||
|
||||
Missing `aptss` is treated as “Spark source not present”, not as “update center failed”.
|
||||
|
||||
That means:
|
||||
|
||||
- No fatal error is thrown solely because `aptss` is missing.
|
||||
- No Spark warning is emitted just because `aptss` is absent.
|
||||
- APM-only results are considered valid update-center output.
|
||||
|
||||
### Both sources unavailable or failing
|
||||
|
||||
If both Spark and APM are unavailable or both real source queries fail, the update center may continue to use the existing combined error path.
|
||||
|
||||
## Testing
|
||||
|
||||
Add a backend unit test covering this scenario:
|
||||
|
||||
1. `aptss` is unavailable.
|
||||
2. APM upgradable and installed commands succeed.
|
||||
3. Spark metadata command is never called.
|
||||
4. `loadUpdateCenterItems()` returns APM items without throwing.
|
||||
|
||||
This test should prove the missing-`aptss` case is handled as a skip rather than an error.
|
||||
@@ -0,0 +1,135 @@
|
||||
# 更新忽略配置迁移设计
|
||||
|
||||
## 背景
|
||||
|
||||
Electron 更新中心已经具备忽略状态的数据通路,但默认仍写入 `/etc/spark-store/ignored_apps.conf`。老 Qt 更新器也沿用同一路径。新架构下更新器不再以 root 身份启动,因此 GUI 无法稳定写入 `/etc`。与此同时,`ss-update-notifier.sh` 以 root systemd 服务运行,若直接使用 `~/` 会错误落到 `/root`。
|
||||
|
||||
本次改动的目标是在不重做更新链路的前提下,把“忽略更新”统一改为用户级配置,并让 Electron、老 Qt 更新器和 notifier 对同一份规则生效。
|
||||
|
||||
## 目标
|
||||
|
||||
1. 忽略配置统一迁移到用户目录 `~/.config/spark-store/ignored_apps.conf`。
|
||||
2. Electron 更新中心支持显式忽略和取消忽略操作。
|
||||
3. 老 Qt 更新器改为读写同一份用户级忽略配置。
|
||||
4. `ss-update-notifier.sh` 在 root systemd 环境下也能读取用户级忽略配置。
|
||||
5. 忽略规则同时作用于 Spark 与 APM 更新项。
|
||||
6. 忽略规则按 `pkgname|version` 精确匹配,被忽略的旧版本在后续出现新版本时应重新提醒。
|
||||
|
||||
## 非目标
|
||||
|
||||
1. 不兼容旧的 `/etc/spark-store/ignored_apps.conf`。
|
||||
2. 不改变更新下载、安装和迁移逻辑。
|
||||
3. 不把忽略配置升级为 JSON 或数据库格式。
|
||||
4. 不修改 AmberPM 侧的 `amber-pm-upgrade-notifier`。
|
||||
|
||||
## 方案概览
|
||||
|
||||
本次实现由三部分组成:
|
||||
|
||||
1. Electron 主进程把忽略配置路径切到用户目录,渲染层补齐“忽略 / 取消忽略”入口,并把已忽略项排在后面展示。
|
||||
2. 老 Qt 更新器的 `IgnoreConfig` 改为使用 `QStandardPaths::ConfigLocation` 下的 `spark-store/ignored_apps.conf`,同时将忽略键统一为“包名 + 新版本”。
|
||||
3. `ss-update-notifier.sh` 新增用户配置定位与扫描逻辑,在 root systemd 环境下优先识别活动桌面用户,失败时回退扫描 `/home/*/.config/spark-store/ignored_apps.conf` 并合并忽略集合。
|
||||
|
||||
## 配置文件设计
|
||||
|
||||
### 路径
|
||||
|
||||
- 统一路径:`~/.config/spark-store/ignored_apps.conf`
|
||||
- Electron 通过当前进程用户的 home 解析该路径。
|
||||
- Qt 通过 `QStandardPaths::writableLocation(QStandardPaths::ConfigLocation)` 解析该路径。
|
||||
- notifier 不直接依赖 `~/`,而是根据目标 home 拼出 `<home>/.config/spark-store/ignored_apps.conf`。
|
||||
|
||||
### 格式
|
||||
|
||||
继续沿用现有纯文本格式,每行一条:
|
||||
|
||||
```text
|
||||
pkgname|version
|
||||
```
|
||||
|
||||
其中 `version` 统一表示“待更新到的新版本”,而不是当前已安装版本。
|
||||
|
||||
### 匹配语义
|
||||
|
||||
1. 仅当 `pkgname` 与 `version` 同时匹配时,视为被忽略。
|
||||
2. 忽略规则不区分 `spark` / `apm` 来源。相同包名与目标版本的更新,在两侧都应被同一条规则命中。
|
||||
3. 某版本被忽略后,未来出现更高版本时,不自动继承忽略状态。
|
||||
|
||||
## Electron 更新中心
|
||||
|
||||
### 主进程
|
||||
|
||||
`electron/main/backend/update-center/ignore-config.ts` 保持文本解析逻辑不变,只修改默认配置路径到用户目录。
|
||||
|
||||
`electron/main/backend/update-center/service.ts` 的默认读写也改用新路径,并在刷新结果上做一次稳定排序:
|
||||
|
||||
1. 正常更新项在前。
|
||||
2. 已忽略项在后。
|
||||
3. 同组内保持原有顺序,避免不必要的 UI 抖动。
|
||||
|
||||
### 渲染层交互
|
||||
|
||||
更新中心列表项新增两个互斥操作:
|
||||
|
||||
1. 未忽略项显示“忽略”按钮。
|
||||
2. 已忽略项显示“取消忽略”按钮。
|
||||
|
||||
交互规则:
|
||||
|
||||
1. 点击“忽略”后调用 `window.updateCenter.ignore({ packageName, newVersion })`。
|
||||
2. 点击“取消忽略”后调用 `window.updateCenter.unignore({ packageName, newVersion })`。
|
||||
3. 主进程刷新完成后,渲染层使用推送或返回的新快照更新列表。
|
||||
4. 已忽略项继续不可勾选,也不会加入“更新选中”任务。
|
||||
|
||||
## 老 Qt 更新器
|
||||
|
||||
### 配置路径
|
||||
|
||||
`IgnoreConfig` 不再尝试写 `/etc`,改为:
|
||||
|
||||
1. 使用 `QStandardPaths::writableLocation(QStandardPaths::ConfigLocation)`。
|
||||
2. 在其下创建 `spark-store/ignored_apps.conf`。
|
||||
|
||||
### 忽略键统一
|
||||
|
||||
Qt 当前交互里,忽略按钮传的是当前版本,检查时也匹配当前版本。这会导致与 Electron 的“目标版本忽略”语义不一致。
|
||||
|
||||
本次统一改为:
|
||||
|
||||
1. 点击“忽略”时写入 `packageName + newVersion`。
|
||||
2. 刷新列表时,用 `packageName + newVersion` 判断是否忽略。
|
||||
|
||||
取消忽略也改为按包名 + 版本删除对应条目,避免误删同包历史忽略记录。
|
||||
|
||||
## `ss-update-notifier.sh`
|
||||
|
||||
### 读取忽略配置
|
||||
|
||||
脚本新增两个步骤:
|
||||
|
||||
1. 尝试定位最可能的桌面用户 home。
|
||||
2. 如果无法可靠定位,则扫描 `/home/*/.config/spark-store/ignored_apps.conf`。
|
||||
|
||||
扫描模式下需要把所有命中的配置文件合并成一个忽略集合,再参与过滤。
|
||||
|
||||
### 过滤规则
|
||||
|
||||
脚本当前只按包名过滤,本次改为按 `pkgname|newVersion` 精确过滤:
|
||||
|
||||
1. 从 `ss-do-upgrade-worker.sh upgradable-list` 读取 `PKG_NAME PKG_NEW_VER PKG_CUR_VER`。
|
||||
2. 构造键 `PKG_NAME|PKG_NEW_VER`。
|
||||
3. 若忽略集合中存在该键,则跳过通知计数。
|
||||
|
||||
### 与通知用户识别解耦
|
||||
|
||||
通知发送仍然尽量复用现有“找活动用户然后 `sudo -u` 发送”的策略,但“读取忽略配置”与“给谁发通知”必须解耦:
|
||||
|
||||
1. 即使没有可靠的当前登录用户,也应先完成忽略过滤。
|
||||
2. 只有在最终需要发送通知时,再尝试解析实际桌面用户。
|
||||
|
||||
## 验证范围
|
||||
|
||||
1. Electron 单元测试覆盖新路径常量、忽略排序与忽略按钮交互。
|
||||
2. Electron 手动验证更新中心忽略 / 取消忽略流程。
|
||||
3. Qt 手动验证忽略后重新打开更新器仍保留状态。
|
||||
4. 手动执行 `ss-update-notifier.sh`,验证 root 环境下能命中用户级忽略配置且按版本精确过滤。
|
||||
@@ -0,0 +1,129 @@
|
||||
# Update Notifier APM Aggregation Design
|
||||
|
||||
## Background
|
||||
|
||||
`tool/update-upgrade/ss-update-notifier.sh` currently counts Spark (`aptss`) updates, filters them through `hold` state and `~/.config/spark-store/ignored_apps.conf`, then sends one desktop notification. A separate APM-side notifier pattern exists, but it is not merged into the current Spark notifier script.
|
||||
|
||||
The goal is to let the current notifier aggregate both Spark and APM upgradable items into one notification, while keeping the existing user-level ignored-update behavior and avoiding hard failures on systems that do not provide `aptss`.
|
||||
|
||||
## Goals
|
||||
|
||||
1. Keep a single notifier script: `tool/update-upgrade/ss-update-notifier.sh`.
|
||||
2. Count both Spark and APM upgradable applications in that script.
|
||||
3. Continue to use one shared ignored-update file: `~/.config/spark-store/ignored_apps.conf`.
|
||||
4. Apply ignored filtering to both Spark and APM using exact `pkgname|newVersion` keys.
|
||||
5. Apply `hold` filtering independently for Spark and APM.
|
||||
6. Aggregate the remaining Spark and APM counts into one notification.
|
||||
7. If `aptss` is unavailable, skip the Spark branch without failing the script.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
1. Do not create a second notifier service or script.
|
||||
2. Do not change the ignored-update file format.
|
||||
3. Do not change Electron or update-center UI behavior in this task.
|
||||
4. Do not add a compatibility layer for `/etc/spark-store/ignored_apps.conf`.
|
||||
|
||||
## Recommended Approach
|
||||
|
||||
Extend the existing notifier in place and keep Spark and APM as two counting branches inside the same script.
|
||||
|
||||
Spark keeps its current `aptss`-based flow. APM adds a second branch that parses `apm list --upgradable`, applies APM `hold` detection via `amber-pm-debug dpkg-query`, and reuses the same ignored-entry set already loaded from user config files. The final notification count becomes `spark_count + apm_count`.
|
||||
|
||||
This keeps the script small, preserves the current Spark path, and avoids introducing a second source of notification truth.
|
||||
|
||||
## Data Sources
|
||||
|
||||
### Spark branch
|
||||
|
||||
- Command availability gate: `command -v aptss`
|
||||
- Refresh commands: `aptss update`, `LANGUAGE=en_US aptss ssupdate`
|
||||
- Upgradable list source: `/opt/durapps/spark-store/bin/update-upgrade/ss-do-upgrade-worker.sh upgradable-list`
|
||||
- Hold check: `dpkg-query -W -f='${db:Status-Want}' <pkg>`
|
||||
|
||||
### APM branch
|
||||
|
||||
- Command availability gate: `command -v apm`
|
||||
- Refresh commands: `LANGUAGE=en_US apm update`, followed by `apm clean`
|
||||
- Upgradable list source: `env LANGUAGE=en_US apm list --upgradable`
|
||||
- Output compatibility: support both `[upgradable from: <version>]` and legacy `[from: <version>]` variants when extracting the current version
|
||||
- Hold check: `amber-pm-debug dpkg-query -W -f='${db:Status-Want}' <pkg>`
|
||||
|
||||
## Filtering Rules
|
||||
|
||||
### Ignored entries
|
||||
|
||||
The script continues to load ignored entries from `~/.config/spark-store/ignored_apps.conf`, using the existing user-detection plus `/home/*` scan behavior.
|
||||
|
||||
Each valid line is still interpreted as:
|
||||
|
||||
```text
|
||||
pkgname|version
|
||||
```
|
||||
|
||||
Matching rule:
|
||||
|
||||
- Spark item is ignored when `pkgname|sparkNewVersion` exists in the ignored set.
|
||||
- APM item is ignored when `pkgname|apmNewVersion` exists in the ignored set.
|
||||
|
||||
Ignored matching is intentionally source-agnostic. If Spark and APM expose the same package name and target version, one ignore entry suppresses both.
|
||||
|
||||
### Hold entries
|
||||
|
||||
- Spark item is excluded if `dpkg-query` reports `hold`.
|
||||
- APM item is excluded if `amber-pm-debug dpkg-query` reports `hold`.
|
||||
|
||||
### Invalid or stale version entries
|
||||
|
||||
Each branch keeps its own version sanity check before counting:
|
||||
|
||||
- Spark continues to skip items where `newVersion <= currentVersion`.
|
||||
- APM does the same after parsing `apm list --upgradable` output from either supported bracket variant.
|
||||
|
||||
## Availability Rules
|
||||
|
||||
### Missing `aptss`
|
||||
|
||||
If `aptss` is not installed or not in `PATH`:
|
||||
|
||||
1. Skip Spark refresh commands entirely.
|
||||
2. Skip Spark upgradable counting entirely.
|
||||
3. Continue with APM counting if `apm` is available.
|
||||
|
||||
### Missing `apm`
|
||||
|
||||
If `apm` is not installed or not in `PATH`:
|
||||
|
||||
1. Skip APM refresh commands entirely.
|
||||
2. Skip APM upgradable counting entirely.
|
||||
3. Continue with Spark counting if `aptss` is available.
|
||||
|
||||
### Both unavailable
|
||||
|
||||
If both `aptss` and `apm` are unavailable, the script exits without sending a notification.
|
||||
|
||||
## Notification Behavior
|
||||
|
||||
The script sends one notification only when:
|
||||
|
||||
```text
|
||||
spark_effective_count + apm_effective_count > 0
|
||||
```
|
||||
|
||||
The notification remains a single desktop message. The implementation may update the wording to mention both Spark and APM updates, but the key requirement is one aggregated notification rather than separate per-source notifications.
|
||||
|
||||
## Implementation Boundaries
|
||||
|
||||
1. Keep the current `detect-notify-user` and ignored-config discovery logic.
|
||||
2. Add APM parsing as a second source-specific helper path instead of rewriting the whole script.
|
||||
3. Keep the shell implementation POSIX-compatible with the current Bash usage already present in the file.
|
||||
4. Avoid changing unrelated installer or update-center code in this task.
|
||||
|
||||
## Verification
|
||||
|
||||
1. `bash -n tool/update-upgrade/ss-update-notifier.sh`
|
||||
2. Manual dry-run reasoning for all four cases:
|
||||
- Spark only
|
||||
- APM only
|
||||
- Spark + APM
|
||||
- neither available
|
||||
3. Confirm ignored entries suppress both branches via exact `pkg|newVersion` matching.
|
||||
@@ -0,0 +1,315 @@
|
||||
# Spark Account Collections Client Design
|
||||
|
||||
## Goal
|
||||
|
||||
Extend the Spark Store account experience across the backend and Electron/Vue client so users can log in or register through the forum identity flow, see account management data, use comments and favorites when logged in, sync store-recognized installed apps, and batch install apps from cloud favorites without breaking anonymous browsing, installation, removal, or update features.
|
||||
|
||||
## Scope
|
||||
|
||||
### Included
|
||||
|
||||
1. Clone or prepare a clean working copy of `https://gitee.com/erotica-rbqs/spark-store` for client implementation.
|
||||
2. Extend the existing FastAPI backend at `https://gitee.com/erotica-rbqs/spark-unionid-server` with account data APIs for favorite folders, favorite items, downloaded records, and richer user profile data.
|
||||
3. Keep login identity based on the Flarum forum. The client posts credentials directly to Flarum, then sends only the Flarum token and user id to the backend.
|
||||
4. The login modal provides a register action by opening the forum registration page in the system browser.
|
||||
5. Replace the upper-left SparkStore title area with an account entry while preserving the logo. After login, replace the logo with the user's avatar and show the username.
|
||||
6. Add a logged-in account quick menu with user management, favorites, forum home, edit forum profile, and logout actions.
|
||||
7. Keep anonymous base usage intact: browsing, searching, detail viewing, software install, software uninstall, update center, and installed-app viewing must work without login.
|
||||
8. Gate account-only features: comments, favorites, cloud favorite management, downloaded record history, and cloud sync require login and show a login/register prompt when used anonymously.
|
||||
9. Convert app details from modal overlay to a main-content detail page that fills the current app-list area, with a back button returning to the previous list state.
|
||||
10. Add favorite actions from the detail page. Users can select a favorite folder; if none exists, the backend creates a default folder.
|
||||
11. Store favorites as application-level identities, not a fixed Spark/APM variant. Batch install selects the currently preferred available variant according to the active priority configuration.
|
||||
12. Record logged-in user downloads to the cloud when the user clicks download. Downloads made while anonymous are not backfilled after login.
|
||||
13. On each client start, refresh installed package lists in the background. For logged-in users, ask once before enabling automatic cloud sync; remember the choice.
|
||||
14. Build the sync list only from store-recognized listed applications, including Spark and APM apps, excluding unknown packages and dependencies.
|
||||
15. User management shows avatar, nickname, Flarum user level, forum home link, and forum profile edit link.
|
||||
16. Users can manage multiple favorite folders with custom names, remove invalid/downlisted apps, select all apps in a folder, and send selected installable apps to the existing download/install queue.
|
||||
17. Favorite folder entries that are not available on the current platform or architecture remain visible with status labels. Downlisted entries remain visible and can be batch removed.
|
||||
|
||||
### Excluded
|
||||
|
||||
1. Client-side Flarum account creation forms. Registration is a browser link to the forum registration page.
|
||||
2. Forum profile editing inside the client. The client opens the forum profile page externally.
|
||||
3. Offline-first conflict resolution for favorites. Backend state is authoritative; client caches may improve UX but do not merge conflicting edits.
|
||||
4. Automatic install on startup. Users must explicitly send favorites or restore items to the download queue.
|
||||
5. Admin moderation, report handling, or anti-spam systems.
|
||||
|
||||
## Existing Context
|
||||
|
||||
The current client is an Electron + Vue 3 + TypeScript application. The target repository is `https://gitee.com/erotica-rbqs/spark-store`.
|
||||
|
||||
Important existing integration points:
|
||||
|
||||
1. `src/App.vue` coordinates tabs, app loading, install queue integration, detail opening, installed-app modal, and update center.
|
||||
2. `src/components/AppHeader.vue` owns the top search/settings/about area.
|
||||
3. `src/components/AppSidebar.vue` owns the current upper-left logo/title area and category navigation.
|
||||
4. `src/components/AppDetailModal.vue` currently renders app details as an overlay modal. It already handles Spark/APM merged apps and source switching.
|
||||
5. `src/components/InstalledAppsModal.vue` renders installed apps and origin switching.
|
||||
6. `src/modules/processInstall.ts` creates download queue items and sends `queue-install` IPC messages.
|
||||
7. `src/global/storeConfig.ts` owns store URLs and hybrid priority rules through `getHybridDefaultOrigin`.
|
||||
8. `electron/main/backend/install-manager.ts` exposes install, remove, `check-installed`, `list-installed`, and availability IPC handlers.
|
||||
9. `list-installed` already supports optimized Spark checks with a `pkgnameList`, and full APM listing marks dependencies by desktop-entry availability. Sync filtering should reuse these facts rather than scanning arbitrary system packages.
|
||||
|
||||
The Spark developer manual identifies this repository as the current Electron GUI store (`apm-app-store` behavior), while `amber-pm` owns package-management semantics after commands leave the GUI.
|
||||
|
||||
## Backend Design
|
||||
|
||||
### User Profile Extension
|
||||
|
||||
The existing backend auth flow remains unchanged at the boundary: `POST /auth/flarum` receives `flarum_user_id` and `flarum_token`, validates the token owner, upserts the local user, and returns a Spark Store JWT.
|
||||
|
||||
The Flarum validation service should also extract user group data from the authenticated actor when available. The backend stores a compact `forum_level` string and optional `forum_groups` JSON/text summary. If Flarum does not expose groups, `forum_level` falls back to `论坛用户`.
|
||||
|
||||
`GET /me` should return existing profile fields plus the forum level fields needed by the client. Existing clients remain compatible because new fields are additive.
|
||||
|
||||
### Favorite Folders
|
||||
|
||||
Add backend tables:
|
||||
|
||||
1. `favorite_folders`: id, user_id, name, created_at, updated_at. Folder names are user-scoped and unique per user. A folder named `默认收藏夹` is created on first favorite action if the user has no folders.
|
||||
2. `favorite_items`: id, folder_id, app_key, pkgname, name, category, icon_url, created_at. Items are unique per folder and `app_key`.
|
||||
|
||||
Favorites are stored as app-level identities. The canonical app key for favorites should be stable across Spark/APM variants when the same user-facing app exists in both sources:
|
||||
|
||||
```text
|
||||
favorite_app_key = app:{category}:{pkgname}
|
||||
```
|
||||
|
||||
The item keeps `pkgname`, display `name`, `category`, and optional `icon_url`. It does not permanently bind to Spark or APM. During install, the client resolves the current catalog entry and chooses Spark/APM according to current availability and priority rules.
|
||||
|
||||
Backend endpoints:
|
||||
|
||||
1. `GET /me/favorite-folders`: list folders with item counts.
|
||||
2. `POST /me/favorite-folders`: create folder with custom name.
|
||||
3. `PATCH /me/favorite-folders/{folder_id}`: rename folder.
|
||||
4. `DELETE /me/favorite-folders/{folder_id}`: delete folder and its items.
|
||||
5. `GET /me/favorite-folders/{folder_id}/items`: list items.
|
||||
6. `POST /me/favorite-folders/{folder_id}/items`: add or idempotently keep an app favorite.
|
||||
7. `DELETE /me/favorite-folders/{folder_id}/items/{item_id}`: remove one item.
|
||||
8. `POST /me/favorite-folders/{folder_id}/items/bulk-delete`: remove selected items, including invalid/downlisted entries.
|
||||
|
||||
All endpoints require JWT.
|
||||
|
||||
### Downloaded Records
|
||||
|
||||
Add `downloaded_apps`: id, user_id, app_key, pkgname, name, category, selected_origin, version, package_arch, downloaded_at.
|
||||
|
||||
Client behavior:
|
||||
|
||||
1. If the user is logged in when clicking download, the client posts a downloaded record after queuing the install task.
|
||||
2. If the user is anonymous, the download proceeds normally and no cloud record is written.
|
||||
3. Logging in later does not backfill anonymous downloads.
|
||||
|
||||
Backend endpoints:
|
||||
|
||||
1. `GET /me/downloaded-apps`: list newest downloaded records with pagination.
|
||||
2. `POST /me/downloaded-apps`: upsert or append a downloaded record. MVP can append history; duplicate suppression by `user_id`, `app_key`, and `selected_origin` is acceptable if tests define it.
|
||||
|
||||
### Installed Sync List
|
||||
|
||||
The existing `GET /me/app-list` and `PUT /me/app-list` endpoints remain the default cloud installed-app list.
|
||||
|
||||
Client sync payload contains only store-recognized listed apps:
|
||||
|
||||
1. App must exist in the current loaded Spark/APM catalog.
|
||||
2. `category !== "unknown"`.
|
||||
3. `isDependency !== true`.
|
||||
4. App has usable `pkgname` and `origin`.
|
||||
|
||||
Unknown system packages, dependencies, and packages not in the store catalog are excluded.
|
||||
|
||||
## Client Design
|
||||
|
||||
### Account Entry And Login
|
||||
|
||||
Move the account entry into the upper-left logo/title area currently owned by the sidebar. The logo remains visible while anonymous. The title text becomes `登录 / 注册` with helper text `星火账号`.
|
||||
|
||||
After login:
|
||||
|
||||
1. The logo image is replaced by the user's avatar when available.
|
||||
2. The main text is the user's display name or username.
|
||||
3. Clicking the account entry opens a quick menu.
|
||||
4. The quick menu includes: `用户管理`, `我的收藏`, `论坛首页`, `修改论坛资料`, and `退出登录`.
|
||||
|
||||
Login modal:
|
||||
|
||||
1. Contains forum account and password inputs.
|
||||
2. Posts credentials directly to Flarum `/api/token`.
|
||||
3. Sends the returned Flarum token and user id to the backend.
|
||||
4. Provides `注册账号` button that opens the Flarum registration page externally.
|
||||
5. Never logs or stores the forum password.
|
||||
|
||||
### Anonymous Behavior
|
||||
|
||||
Anonymous users can still:
|
||||
|
||||
1. Browse homepage and categories.
|
||||
2. Search.
|
||||
3. View app details.
|
||||
4. Download/install apps.
|
||||
5. Remove installed apps.
|
||||
6. Use update center.
|
||||
7. View installed apps.
|
||||
|
||||
When anonymous users use account-only actions, show a login/register prompt instead of blocking the whole page. Account-only actions are comments, submit review, favorite, cloud favorites, downloaded history, and cloud sync.
|
||||
|
||||
### Detail Page
|
||||
|
||||
Replace the overlay detail modal with a main-content detail page inside the same area currently used by `AppGrid` and `HomeView`.
|
||||
|
||||
State model:
|
||||
|
||||
1. `currentView` or equivalent distinguishes `list`, `home`, and `detail`.
|
||||
2. Opening an app stores the previous list context and selected app.
|
||||
3. Back returns to the prior list/search/category state.
|
||||
4. Screen preview can remain an overlay because it is secondary media UI.
|
||||
|
||||
The detail page keeps existing detail capabilities:
|
||||
|
||||
1. Spark/APM merged app source switch.
|
||||
2. Install/open/remove actions.
|
||||
3. Metadata and screenshots.
|
||||
4. Download count.
|
||||
|
||||
New detail capabilities:
|
||||
|
||||
1. Favorite button.
|
||||
2. Favorite folder selector.
|
||||
3. Comments/reviews panel with login prompt for anonymous users.
|
||||
4. Download click writes a cloud downloaded record only when logged in.
|
||||
|
||||
### Favorites Management
|
||||
|
||||
The user management area includes favorite folder management.
|
||||
|
||||
Users can:
|
||||
|
||||
1. List favorite folders.
|
||||
2. Create folders with custom names.
|
||||
3. Rename folders.
|
||||
4. Delete folders.
|
||||
5. View folder items.
|
||||
6. Remove selected items.
|
||||
7. Batch remove invalid/downlisted items.
|
||||
8. Select all available items and send them to the download queue.
|
||||
|
||||
Availability resolution is client-side because it depends on the current catalog, architecture, Spark/APM availability, store filter, and priority rules.
|
||||
|
||||
Favorite item states:
|
||||
|
||||
1. `installable`: found in catalog and current source/architecture can install a preferred variant.
|
||||
2. `installed`: already installed locally.
|
||||
3. `platform-unavailable`: item exists but neither Spark nor APM variant is usable under current store filter/capability.
|
||||
4. `arch-unavailable`: item exists in catalog metadata but not for the current architecture.
|
||||
5. `downlisted`: item no longer exists in the loaded catalog.
|
||||
|
||||
Batch install uses current preference:
|
||||
|
||||
1. If only one usable variant exists, use it.
|
||||
2. If both Spark and APM variants exist, use `getHybridDefaultOrigin` and the current store filter/availability to choose.
|
||||
3. If the preferred variant is unavailable, use the other usable variant.
|
||||
4. If no usable variant exists, do not queue it and show the reason.
|
||||
|
||||
### Downloaded Records
|
||||
|
||||
When a logged-in user clicks download/install from detail, favorites, restore, or installed sync restore flows, the client records the selected app to the backend after the queue item is created.
|
||||
|
||||
Downloaded records are visible in user management. They are informational and do not alter the install queue automatically.
|
||||
|
||||
### Startup Installed Sync
|
||||
|
||||
On startup, the client refreshes installed packages in the background after the catalog is loaded enough to provide package lists.
|
||||
|
||||
Flow:
|
||||
|
||||
1. Load Spark/APM catalog as normal.
|
||||
2. Call existing `list-installed` IPC for enabled origins. Use optimized package-name checks where possible for Spark and full APM listing where needed, following the current installed modal/update-center patterns.
|
||||
3. Merge results with catalog metadata.
|
||||
4. Filter to store-recognized non-dependency apps.
|
||||
5. If logged in and the user has not made a cloud sync decision, ask once whether to enable automatic installed-list sync.
|
||||
6. If enabled, upload the default app list via `PUT /me/app-list`.
|
||||
7. If disabled or anonymous, keep the refreshed list local only.
|
||||
|
||||
The confirmation preference is stored locally. A user can later change it from user management.
|
||||
|
||||
## Data Flow
|
||||
|
||||
### Login
|
||||
|
||||
1. User opens login modal from the upper-left account entry.
|
||||
2. Client posts credentials to Flarum `/api/token`.
|
||||
3. Flarum returns token and user id.
|
||||
4. Client posts token and user id to backend `/auth/flarum`.
|
||||
5. Backend validates token owner, stores profile and forum level, and returns Spark JWT.
|
||||
6. Client stores Spark JWT and profile in local auth state.
|
||||
|
||||
### Favorite Add
|
||||
|
||||
1. Logged-in user clicks favorite on detail page.
|
||||
2. Client fetches or creates favorite folders if needed.
|
||||
3. User selects a folder.
|
||||
4. Client posts app-level identity to backend favorite item endpoint.
|
||||
5. UI updates folder item count and favorite state.
|
||||
|
||||
### Batch Install From Favorites
|
||||
|
||||
1. User opens a favorite folder.
|
||||
2. Client resolves each favorite item against current catalog and install facts.
|
||||
3. UI shows installable, installed, unavailable, arch-unavailable, and downlisted states.
|
||||
4. User selects installable items or clicks select all installable.
|
||||
5. Client maps each item to the chosen Spark/APM `App` object based on current priority rules.
|
||||
6. Client calls existing `handleInstall(app)` for each selected item.
|
||||
7. If logged in, client records downloaded apps to backend after queueing.
|
||||
|
||||
## Error Handling
|
||||
|
||||
1. Login failure from Flarum or backend shows a local error in the login modal and clears partial auth state.
|
||||
2. Expired backend JWT logs the user out or prompts re-login when an account endpoint returns `401`.
|
||||
3. Favorites and downloaded-record failures do not block base install; show a non-fatal account sync error.
|
||||
4. Startup sync failure does not block app startup; it shows a user-management warning and can be retried manually.
|
||||
5. Batch install skips unavailable/downlisted items and reports counts per state.
|
||||
6. Backend rejects extra fields and invalid lengths with `422`.
|
||||
|
||||
## Testing And Verification
|
||||
|
||||
Backend tests:
|
||||
|
||||
1. Favorite folder creation, default folder creation, rename, delete, and user isolation.
|
||||
2. Favorite item add/remove/idempotency and folder scoping.
|
||||
3. Downloaded record create/list and user isolation.
|
||||
4. Forum level extraction fallback.
|
||||
5. Existing auth/reviews/app-list tests remain passing.
|
||||
6. Alembic migration upgrade succeeds.
|
||||
|
||||
Client tests:
|
||||
|
||||
1. Account entry anonymous/logged-in rendering and quick menu actions.
|
||||
2. Login modal emits login and opens register URL.
|
||||
3. Auth state persistence and backend token handling.
|
||||
4. Detail page replaces the list area and back returns to list state.
|
||||
5. Favorite folder selector login gating and folder selection.
|
||||
6. Favorite availability resolver for installable, installed, platform-unavailable, arch-unavailable, and downlisted states.
|
||||
7. Batch install selects Spark/APM variant according to current priority rules.
|
||||
8. Startup sync filtering includes only store-listed non-dependency Spark/APM apps.
|
||||
9. User management renders profile, forum level, forum links, favorite folders, downloaded records, and sync preference.
|
||||
10. Existing install, update center, installed apps, search, and grid tests remain passing.
|
||||
|
||||
Final client verification:
|
||||
|
||||
1. `npm run test`
|
||||
2. `npm run lint`
|
||||
3. `npm run build:vite`
|
||||
|
||||
Final backend verification:
|
||||
|
||||
1. `.venv/bin/pytest -v`
|
||||
2. `DATABASE_URL=<fresh test url> .venv/bin/alembic upgrade head`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
1. Use a clean client worktree or fresh clone from `https://gitee.com/erotica-rbqs/spark-store` before implementing. Do not mix previous untracked planning artifacts into code commits.
|
||||
2. Keep backend and client commits separate.
|
||||
3. Add `.superpowers/` to `.gitignore` so visual companion artifacts remain local.
|
||||
4. Preserve existing IPC contracts for install and update flows.
|
||||
5. Do not change `amber-pm` package-management behavior; only reuse GUI-side install queue paths.
|
||||
6. Keep UI components focused: account entry/menu, login modal, detail page, favorite selector, user management, and favorite folder manager should be separate components rather than expanding `App.vue` further.
|
||||
@@ -0,0 +1,374 @@
|
||||
# Spark Account Reviews Sync Design
|
||||
|
||||
## Goal
|
||||
|
||||
Add a first-version account feature to Spark Store that uses the existing Flarum forum at `https://bbs.spark-app.store/` as the identity provider, while storing Spark Store-specific data in a new Python + MySQL backend.
|
||||
|
||||
The MVP covers:
|
||||
|
||||
1. Login with a Flarum account.
|
||||
2. Show the logged-in user's avatar and display name in the client.
|
||||
3. Show and submit app detail page comments with 1-5 star ratings.
|
||||
4. Attach immutable automatic local tags to reviews and support review filtering by those tags.
|
||||
5. Sync the user's local store-recognized app list so a new device can quickly reinstall old apps.
|
||||
6. Create a new backend Git repository named `spark-store-backend`.
|
||||
|
||||
## Scope
|
||||
|
||||
### Included In MVP
|
||||
|
||||
1. Client-side Flarum token login.
|
||||
2. Backend validation of Flarum tokens and backend JWT issuance.
|
||||
3. User profile display in the Spark Store client.
|
||||
4. Review list, rating summary, review creation, and editing the current user's own review.
|
||||
5. Review filtering by automatic tags: app version, package architecture, client architecture, distro, origin, and category.
|
||||
6. A default per-user cloud app list that is overwritten on each sync.
|
||||
7. Restore UI that lets users choose cloud-list apps and add them to the existing install queue.
|
||||
|
||||
### Excluded From MVP
|
||||
|
||||
1. Admin moderation UI.
|
||||
2. Review reports, bans, anti-spam scoring, or manual approval workflows.
|
||||
3. Synchronizing reviews into Flarum discussions.
|
||||
4. Multiple named app-list snapshots or historical list versions.
|
||||
5. Syncing unknown system packages, dependencies, or every package from `dpkg`/APM.
|
||||
6. Automatic unattended install on a new device.
|
||||
|
||||
## Existing Client Context
|
||||
|
||||
The current Spark Store client in this repository is an Electron + Vue 3 + TypeScript app.
|
||||
|
||||
Important existing integration points:
|
||||
|
||||
1. `src/components/AppDetailModal.vue` owns the app detail modal. It already renders app metadata, screenshots, install/open/remove actions, and download counts.
|
||||
2. `src/components/InstalledAppsModal.vue` owns the installed-app list UI.
|
||||
3. `src/App.vue` coordinates modal state, installed-app loading, detail opening, and existing install queue calls.
|
||||
4. `electron/main/backend/install-manager.ts` already exposes `check-installed` and `list-installed` IPC handlers.
|
||||
5. Existing install queue behavior should be reused for restore installs rather than creating a second install system.
|
||||
|
||||
## Architecture
|
||||
|
||||
Use the lightweight new-backend architecture confirmed during brainstorming.
|
||||
|
||||
Components:
|
||||
|
||||
1. Spark Store client: Vue/Electron UI, local app metadata, local package detection, and install queue integration.
|
||||
2. Flarum forum: authoritative identity provider for forum username, display name, and avatar.
|
||||
3. New Python backend: FastAPI service that verifies Flarum tokens, signs Spark Store JWTs, and owns reviews, ratings, and app-list sync data.
|
||||
4. MySQL database: persistent storage for local user mappings, app keys, reviews, rating aggregates, and synced app-list items.
|
||||
|
||||
The new backend must not receive the user's forum password in the selected MVP flow.
|
||||
|
||||
## Authentication Flow
|
||||
|
||||
1. The client shows a login form for the Flarum username/email and password.
|
||||
2. The client posts credentials directly to Flarum's token API.
|
||||
3. Flarum returns an access token and user id.
|
||||
4. The client sends the Flarum token and user id to the new backend `POST /auth/flarum`.
|
||||
5. The backend calls Flarum API with that token to verify it and retrieve the user profile.
|
||||
6. The backend upserts a local user row keyed by `flarum_user_id`.
|
||||
7. The backend returns a Spark Store JWT plus public profile fields: display name, username, avatar URL, and Flarum user id.
|
||||
8. The client stores the Spark Store JWT for backend calls and displays the avatar/name in the header or account area.
|
||||
|
||||
Logout clears local Spark Store auth state. MVP logout does not need to revoke the Flarum token remotely.
|
||||
|
||||
## Review And Rating Design
|
||||
|
||||
### App Identity
|
||||
|
||||
Reviews are keyed by a stable app key derived from store metadata:
|
||||
|
||||
```text
|
||||
app_key = {origin}:{store_arch}:{category}:{pkgname}
|
||||
```
|
||||
|
||||
Examples:
|
||||
|
||||
```text
|
||||
spark:amd64-store:tools:spark-store
|
||||
apm:amd64-apm:office:wps
|
||||
```
|
||||
|
||||
This separates Spark and APM apps when they share a package name, while still allowing the UI to show each source independently in the existing hybrid detail modal.
|
||||
|
||||
### Automatic Review Tags
|
||||
|
||||
When a logged-in user writes a review, the client sends automatic tags derived from the currently viewed app and local system. The user can preview these tags but cannot edit them.
|
||||
|
||||
Required tags:
|
||||
|
||||
1. `origin`: `spark` or `apm`.
|
||||
2. `category`: current store category.
|
||||
3. `pkgname`: current package name.
|
||||
4. `version`: current app/package version shown in the detail page.
|
||||
5. `package_arch`: package architecture if known from installed-app data or store filename metadata.
|
||||
6. `client_arch`: `window.apm_store.arch` such as `amd64`, `arm64`, or `loong64`.
|
||||
7. `distro`: local Linux distribution id/version when available from the Electron main process.
|
||||
|
||||
If `package_arch` or `distro` cannot be detected, the client sends an empty string or `unknown`; the backend stores the value exactly as submitted after validation.
|
||||
|
||||
### Review UI
|
||||
|
||||
Add a `ReviewsPanel`-style component inside `AppDetailModal.vue`, below the existing app description and screenshot sections.
|
||||
|
||||
Behavior:
|
||||
|
||||
1. Anonymous users can read reviews and rating summary.
|
||||
2. Anonymous users see a login prompt instead of the review composer.
|
||||
3. Logged-in users can select a 1-5 rating and submit text content.
|
||||
4. The composer displays the automatic tags as read-only pills.
|
||||
5. The list supports filters for current version, current architecture, current distro, origin, category, and rating.
|
||||
6. Users can switch filters to view comments under other versions or architectures.
|
||||
7. The review list uses pagination or cursor-based loading to avoid loading all reviews at once.
|
||||
|
||||
### Review Rules
|
||||
|
||||
MVP review rules:
|
||||
|
||||
1. Rating must be an integer from 1 to 5.
|
||||
2. Content must be non-empty after trimming and have a backend-enforced maximum length.
|
||||
3. A user can have one active review per `app_key` plus exact automatic-tag tuple.
|
||||
4. Submitting again for the same `app_key` and tag tuple updates the existing review.
|
||||
5. Backend timestamps use UTC.
|
||||
|
||||
## App List Sync Design
|
||||
|
||||
### Upload Source
|
||||
|
||||
The client uses the existing installed-app flow as the source of local software state.
|
||||
|
||||
For MVP, upload only apps that satisfy all conditions:
|
||||
|
||||
1. App exists in the Spark/APM store catalog loaded by the client.
|
||||
2. `category !== "unknown"`.
|
||||
3. `isDependency !== true`.
|
||||
4. The app has a usable `pkgname` and `origin`.
|
||||
|
||||
This intentionally excludes unknown system packages, dependencies, and packages that the store cannot reinstall safely.
|
||||
|
||||
### Sync UI
|
||||
|
||||
Extend `InstalledAppsModal.vue` with account-aware actions:
|
||||
|
||||
1. `同步到账号`: uploads the filtered installed app list as the user's default cloud list.
|
||||
2. `从账号恢复`: fetches the default cloud list and opens a restore selection view.
|
||||
|
||||
The restore view shows each cloud item with one of these states:
|
||||
|
||||
1. Already installed locally.
|
||||
2. Available to install on this client.
|
||||
3. Not available for the current architecture/source.
|
||||
|
||||
The user explicitly selects items and starts restore. Restore uses the existing `handleInstall` and install queue path in the client.
|
||||
|
||||
### Cloud List Semantics
|
||||
|
||||
MVP maintains one default cloud app list per user.
|
||||
|
||||
Each successful sync replaces the previous default list. This avoids list merge conflicts in the first version.
|
||||
|
||||
## Backend Repository
|
||||
|
||||
Create a new sibling repository:
|
||||
|
||||
```text
|
||||
/home/spark/Desktop/shenmo-spark-store/spark-store-backend
|
||||
```
|
||||
|
||||
Initialize it as a new Git repository and set origin to:
|
||||
|
||||
```text
|
||||
https://gitee.com/momen_official/spark-store-backend.git
|
||||
```
|
||||
|
||||
The initial repository should include a `README.md`. Feature implementation can then add backend code, migrations, tests, and configuration templates.
|
||||
|
||||
## Backend Technology
|
||||
|
||||
Use:
|
||||
|
||||
1. FastAPI for HTTP APIs.
|
||||
2. SQLAlchemy for ORM models.
|
||||
3. Alembic for database migrations.
|
||||
4. PyMySQL or mysqlclient for MySQL connectivity.
|
||||
5. Pydantic settings for environment configuration.
|
||||
6. Pytest for backend tests.
|
||||
|
||||
Configuration should come from environment variables, with `.env.example` committed and real `.env` ignored.
|
||||
|
||||
## Backend API
|
||||
|
||||
### Auth
|
||||
|
||||
`POST /auth/flarum`
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"flarum_user_id": "123",
|
||||
"flarum_token": "..."
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"access_token": "spark-store-jwt",
|
||||
"token_type": "bearer",
|
||||
"user": {
|
||||
"id": 1,
|
||||
"flarum_user_id": "123",
|
||||
"username": "shenmo",
|
||||
"display_name": "shenmo",
|
||||
"avatar_url": "https://..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`GET /me`
|
||||
|
||||
Returns the current backend user profile from the Spark Store JWT.
|
||||
|
||||
### Reviews
|
||||
|
||||
`GET /apps/{app_key}/rating-summary`
|
||||
|
||||
Returns average rating, review count, and per-star counts.
|
||||
|
||||
`GET /apps/{app_key}/reviews`
|
||||
|
||||
Query parameters:
|
||||
|
||||
1. `version`
|
||||
2. `package_arch`
|
||||
3. `client_arch`
|
||||
4. `distro`
|
||||
5. `origin`
|
||||
6. `category`
|
||||
7. `rating`
|
||||
8. `page` and `page_size`
|
||||
|
||||
`POST /apps/{app_key}/reviews`
|
||||
|
||||
Requires JWT. Creates or updates the current user's review for the same app and automatic-tag tuple.
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"rating": 5,
|
||||
"content": "Works well on my machine.",
|
||||
"tags": {
|
||||
"origin": "apm",
|
||||
"category": "office",
|
||||
"pkgname": "wps",
|
||||
"version": "1.0.0",
|
||||
"package_arch": "amd64",
|
||||
"client_arch": "amd64",
|
||||
"distro": "deepin 25"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### App List Sync
|
||||
|
||||
`GET /me/app-list`
|
||||
|
||||
Requires JWT. Returns the current user's default cloud app list.
|
||||
|
||||
`PUT /me/app-list`
|
||||
|
||||
Requires JWT. Replaces the current user's default cloud app list.
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"client_arch": "amd64",
|
||||
"distro": "deepin 25",
|
||||
"items": [
|
||||
{
|
||||
"pkgname": "spark-store",
|
||||
"origin": "spark",
|
||||
"category": "tools",
|
||||
"version": "5.1.1",
|
||||
"package_arch": "amd64",
|
||||
"app_name": "Spark Store",
|
||||
"icon_url": "https://..."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`POST /me/app-list/install-plan`
|
||||
|
||||
Requires JWT. Accepts current client catalog/install facts and returns a normalized plan with installed, installable, and unavailable items. The client may also compute this locally, but the endpoint gives the backend a stable contract for future clients.
|
||||
|
||||
## Database Model
|
||||
|
||||
Tables:
|
||||
|
||||
1. `users`: local user id, Flarum user id, username, display name, avatar URL, timestamps.
|
||||
2. `apps`: app key, pkgname, origin, store arch, category, latest seen version, timestamps.
|
||||
3. `reviews`: app id, user id, rating, content, automatic tag columns, timestamps.
|
||||
4. `user_app_lists`: user id, snapshot name, client arch, distro, timestamps.
|
||||
5. `user_app_list_items`: list id, pkgname, origin, category, version, package arch, app name, icon URL, timestamps.
|
||||
|
||||
Indexes:
|
||||
|
||||
1. Unique `users.flarum_user_id`.
|
||||
2. Unique `apps.app_key`.
|
||||
3. Index `reviews.app_id` plus tag filter columns.
|
||||
4. Unique review key for user, app, version, package arch, client arch, distro, origin, and category.
|
||||
5. Unique default app list per user.
|
||||
|
||||
## Error Handling
|
||||
|
||||
Client behavior:
|
||||
|
||||
1. If Flarum login fails, show a login error without contacting the backend.
|
||||
2. If backend token exchange fails, show a backend login error and clear partial auth state.
|
||||
3. If review loading fails, keep the app detail page usable and show a retry affordance in the review panel.
|
||||
4. If app-list sync fails, keep the local installed-app modal usable and show the failure message near the sync action.
|
||||
5. If restore install queuing fails for one item, keep the remaining selected items visible and report which item failed.
|
||||
|
||||
Backend behavior:
|
||||
|
||||
1. Invalid Flarum token returns `401`.
|
||||
2. Invalid JWT returns `401`.
|
||||
3. Invalid app key, rating, or tag payload returns `422`.
|
||||
4. Database errors return `500` with safe generic messages and structured server logs.
|
||||
|
||||
## Security Notes
|
||||
|
||||
1. The new backend never receives forum passwords in the selected MVP architecture.
|
||||
2. Real secrets, JWT keys, database URLs, and Flarum tokens must not be committed.
|
||||
3. The Electron client must avoid logging Flarum tokens and backend JWTs.
|
||||
4. Backend JWT expiry should be finite; refresh can be handled by reauth in MVP.
|
||||
5. CORS should be restricted to expected client origins in production.
|
||||
|
||||
## Testing And Verification
|
||||
|
||||
Client verification:
|
||||
|
||||
1. Unit tests for review tag construction.
|
||||
2. Unit tests for installed-app sync filtering.
|
||||
3. Component tests for review panel anonymous/logged-in states.
|
||||
4. Component tests for sync and restore UI states.
|
||||
5. Existing `npm run lint` and `npm run build:vite` after implementation.
|
||||
|
||||
Backend verification:
|
||||
|
||||
1. Unit/API tests for `/auth/flarum` with mocked Flarum responses.
|
||||
2. API tests for review creation, update, listing, filtering, and rating summary.
|
||||
3. API tests for app-list upload and retrieval.
|
||||
4. Migration verification against MySQL or a compatible test database.
|
||||
|
||||
## Open Implementation Notes
|
||||
|
||||
1. Detecting `distro` should be done through Electron main process IPC, preferably by reading `/etc/os-release` and exposing a small safe object to the renderer.
|
||||
2. Package architecture can come from installed-app data when available; otherwise parse from filename only if reliable, falling back to `unknown`.
|
||||
3. The existing `AppDetailModal.vue` is already large, so review UI should be isolated into a new child component rather than expanding all logic inline.
|
||||
4. Restore installation should reuse existing app lookup and `handleInstall` code to preserve Spark/APM origin behavior.
|
||||
@@ -0,0 +1,43 @@
|
||||
# App Detail Fixed Sidebar Scroll Design
|
||||
|
||||
## Goal
|
||||
|
||||
In the app detail popup, keep the left app action/meta area fixed on desktop while only the right content area scrolls. Preserve the existing modal/popup visual style and mobile behavior.
|
||||
|
||||
## Scope
|
||||
|
||||
This change only affects `src/components/AppDetailModal.vue` and its unit tests. It does not change app identity, review behavior, install behavior, or the surrounding `App.vue` modal flow.
|
||||
|
||||
## Desktop Layout
|
||||
|
||||
For `lg` and wider screens:
|
||||
|
||||
1. The modal overlay remains a centered popup with the existing dim background.
|
||||
2. `.modal-panel` keeps the rounded card style but stops being the scroll container.
|
||||
3. `.modal-panel` uses a bounded height and `overflow-hidden` so the popup itself stays fixed.
|
||||
4. The modal body is split into two columns.
|
||||
5. The left column contains the return button, app icon/name, source selector, install/open/remove/favorite buttons, and metadata. This column does not scroll with wheel movement over the right side.
|
||||
6. The right column contains app details, screenshots, and reviews. This column is the independent vertical scroll container.
|
||||
|
||||
## Mobile Layout
|
||||
|
||||
Below `lg`, keep the existing stacked modal behavior. The modal remains scrollable as a single column so small screens can still access all controls and content.
|
||||
|
||||
## Scroll Behavior
|
||||
|
||||
Wheel scrolling over normal modal content should scroll the right content column on desktop. The overlay wheel guard remains in place so the background page does not scroll through the modal.
|
||||
|
||||
## Testing
|
||||
|
||||
Update `AppDetailModal.test.ts` to assert:
|
||||
|
||||
1. The popup still renders as a fixed modal overlay with `.modal-panel`.
|
||||
2. `.modal-panel` uses `overflow-hidden` instead of being the primary vertical scroll container on desktop.
|
||||
3. The left fixed column and right scroll column have stable test selectors.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. Desktop: left A column remains visually fixed while right B column scrolls.
|
||||
2. Mobile: stacked layout remains usable.
|
||||
3. Existing detail modal behavior remains intact.
|
||||
4. Unit tests and Vite build pass.
|
||||
@@ -0,0 +1,59 @@
|
||||
# Client UI Polish Design
|
||||
|
||||
## Goal
|
||||
|
||||
Fix the client-side account, favorites, review, sync, and shell UI issues reported during QA without changing backend contracts in this pass.
|
||||
|
||||
## Scope
|
||||
|
||||
This pass is client-only. It can change Vue components, renderer state, Electron window chrome, tests, and docs. It must not invent successful backend behavior for review likes, replies, or deletes until backend endpoints exist.
|
||||
|
||||
## User-Visible Requirements
|
||||
|
||||
- Long account names must not overflow the sidebar account entry or account quick menu.
|
||||
- Account quick-menu actions must close the menu immediately after selection.
|
||||
- User management must open as a global modal/popup overlay instead of replacing only the right content frame.
|
||||
- User management should render a profile-cover hero when a user has a background/cover URL, with a safe fallback when absent.
|
||||
- Favorites should not show duplicate default-folder entries.
|
||||
- The favorite selector must expose a visible new-folder action.
|
||||
- Clicking a favorite item row should open app detail; the checkbox should remain only for bulk selection.
|
||||
- The detail favorite button should show favorited state as `已收藏`, and opening it should allow switching folder or cancelling the favorite when client data can identify the existing item.
|
||||
- Review rating input should use clickable/star UI instead of a native select.
|
||||
- Reviews should expose architecture and OS/distro filters.
|
||||
- Review cards should expose user detail entry points from avatar/name and show client-side action affordances for like/reply/delete. Delete must be visibly limited to the author/admin; persistence is deferred until backend APIs exist.
|
||||
- The system title bar should be replaced by a frameless Electron window with an app-rendered title bar and window controls.
|
||||
- Selected category capsule color must remain `#2B7FFF`.
|
||||
- Manual sync must show visible feedback where the user clicked it.
|
||||
- Restore-from-account should resolve cloud items by same origin first, then fall back to same package across Spark/APM when the exact origin is not loaded.
|
||||
|
||||
## Architecture
|
||||
|
||||
Keep existing component boundaries and make small additions:
|
||||
|
||||
- `App.vue` remains the state coordinator for account modals, favorites, sync, and restore.
|
||||
- Add a small reusable `UserManagementModal.vue` wrapper around existing `UserManagementView.vue` instead of restructuring the view.
|
||||
- Add a `WindowTitleBar.vue` component and Electron IPC handlers for minimize/maximize/close, preserving the existing close-to-tray guard.
|
||||
- Keep review API calls in `backendApi.ts`; client-only review actions emit UI feedback until backend endpoints are added.
|
||||
- Add pure helper functions for sync restore candidate resolution so cross-source matching can be unit-tested without mounting the full app.
|
||||
|
||||
## Data Flow
|
||||
|
||||
- Account menu emits actions to `App.vue`; `App.vue` toggles modal state and loads downloaded history before showing user management.
|
||||
- Favorites are loaded from existing folder/item endpoints. `App.vue` computes current detail favorite metadata from loaded folders/items and passes status to detail components.
|
||||
- Review filters are local to `ReviewsPanel.vue`; they filter loaded review records by package architecture and distro string.
|
||||
- Sync feedback is stored in existing `syncStatusMessage` and passed to both `UserManagementView` and `InstalledAppsModal`.
|
||||
- Restore resolution uses `resolveCloudInstallCandidate(item, apps)` with exact origin/category preference and package-name fallback.
|
||||
|
||||
## Deferred Backend Work
|
||||
|
||||
The following need backend API work later:
|
||||
|
||||
- Persistent review likes.
|
||||
- Review replies and reply listing.
|
||||
- Server-authorized review deletion.
|
||||
- Fetching arbitrary forum user profile details and cover images. This client pass supports cover URLs when present in `SparkUser`; it does not scrape forum HTML.
|
||||
|
||||
## Testing
|
||||
|
||||
- Add/update unit tests for account modal behavior, quick-menu closing, user cover rendering, favorites selector normalization, star rating, review filters/actions, restore candidate resolution, and titlebar IPC/config.
|
||||
- Run targeted Vitest files and `npm run build:vite` before completion.
|
||||
@@ -15,7 +15,7 @@ extraResources:
|
||||
to: "icons"
|
||||
|
||||
linux:
|
||||
icon: "icons/amber-pm-logo.icns"
|
||||
icon: "icons/spark-store.png"
|
||||
category: "System"
|
||||
executableName: "spark-store"
|
||||
desktop:
|
||||
@@ -26,6 +26,7 @@ linux:
|
||||
Categories: "System;"
|
||||
mimeTypes:
|
||||
- "x-scheme-handler/spk"
|
||||
- "x-scheme-handler/apt"
|
||||
target:
|
||||
- "AppImage"
|
||||
- "deb"
|
||||
|
||||
@@ -1,15 +1,33 @@
|
||||
import { BrowserWindow, dialog, ipcMain, WebContents } from "electron";
|
||||
import { spawn, ChildProcess, exec } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { ipcMain, WebContents } from "electron";
|
||||
import { spawn, ChildProcess } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import pino from "pino";
|
||||
|
||||
import { ChannelPayload } from "../../typedefinition";
|
||||
import axios from "axios";
|
||||
import { findExecutable, SUPER_USER_COMMAND_CANDIDATES } from "./superuser";
|
||||
|
||||
const logger = pino({ name: "install-manager" });
|
||||
|
||||
const getStoreFilterFromArgv = (): "spark" | "apm" | "both" => {
|
||||
const argv = process.argv;
|
||||
const noApm = argv.includes("--no-apm");
|
||||
const noSpark = argv.includes("--no-spark");
|
||||
|
||||
if (noApm && noSpark) return "both";
|
||||
if (noApm) return "spark";
|
||||
if (noSpark) return "apm";
|
||||
return "both";
|
||||
};
|
||||
|
||||
const isOriginEnabled = (
|
||||
storeFilter: "spark" | "apm" | "both",
|
||||
origin: "spark" | "apm",
|
||||
): boolean => {
|
||||
return storeFilter === "both" || storeFilter === origin;
|
||||
};
|
||||
|
||||
type InstallTask = {
|
||||
id: number;
|
||||
pkgname: string;
|
||||
@@ -32,22 +50,18 @@ export const tasks = new Map<number, InstallTask>();
|
||||
let idle = true; // Indicates if the installation manager is idle
|
||||
|
||||
export const checkSuperUserCommand = async (): Promise<string> => {
|
||||
let superUserCmd = "";
|
||||
const execAsync = promisify(exec);
|
||||
if (process.getuid && process.getuid() !== 0) {
|
||||
const { stdout, stderr } = await execAsync("which /usr/bin/pkexec");
|
||||
if (stderr) {
|
||||
logger.error("没有找到 pkexec 命令");
|
||||
return;
|
||||
}
|
||||
logger.info(`找到提升权限命令: ${stdout.trim()}`);
|
||||
superUserCmd = stdout.trim();
|
||||
if (process.getuid?.() === 0) return "";
|
||||
|
||||
if (superUserCmd.length === 0) {
|
||||
logger.error("没有找到提升权限的命令 pkexec!");
|
||||
}
|
||||
}
|
||||
for (const command of SUPER_USER_COMMAND_CANDIDATES) {
|
||||
const superUserCmd = await findExecutable(command);
|
||||
if (superUserCmd.length > 0) {
|
||||
logger.info(`找到提升权限命令: ${superUserCmd}`);
|
||||
return superUserCmd;
|
||||
}
|
||||
}
|
||||
|
||||
logger.error("没有找到提升权限的命令 pkexec!");
|
||||
return "";
|
||||
};
|
||||
|
||||
const runCommandCapture = async (execCommand: string, execParams: string[]) => {
|
||||
@@ -88,23 +102,12 @@ const checkApmAvailable = async (): Promise<boolean> => {
|
||||
return found;
|
||||
};
|
||||
|
||||
/** 提权执行 shell-caller aptss install apm 安装 APM,安装后需用户重启电脑 */
|
||||
const runInstallApm = async (superUserCmd: string): Promise<boolean> => {
|
||||
const execCommand = superUserCmd || SHELL_CALLER_PATH;
|
||||
const execParams = superUserCmd
|
||||
? [SHELL_CALLER_PATH, "aptss", "install", "apm"]
|
||||
: [SHELL_CALLER_PATH, "aptss", "install", "apm"];
|
||||
logger.info(`执行安装 APM: ${execCommand} ${execParams.join(" ")}`);
|
||||
const { code, stdout, stderr } = await runCommandCapture(
|
||||
execCommand,
|
||||
execParams,
|
||||
);
|
||||
if (code !== 0) {
|
||||
logger.error({ code, stdout, stderr }, "安装 APM 失败");
|
||||
return false;
|
||||
}
|
||||
logger.info("安装 APM 完成");
|
||||
return true;
|
||||
/** 检测本机是否具备 Spark/aptss 管理能力 */
|
||||
const checkSparkAvailable = async (): Promise<boolean> => {
|
||||
const { code, stdout } = await runCommandCapture("which", ["aptss"]);
|
||||
const found = code === 0 && stdout.trim().length > 0;
|
||||
if (!found) logger.info("未检测到 aptss 命令");
|
||||
return found;
|
||||
};
|
||||
|
||||
const parseUpgradableList = (output: string) => {
|
||||
@@ -148,8 +151,7 @@ ipcMain.on("queue-install", async (event, download_json) => {
|
||||
typeof download_json === "string"
|
||||
? JSON.parse(download_json)
|
||||
: download_json;
|
||||
const { id, pkgname, metalinkUrl, filename, upgradeOnly, origin } =
|
||||
download || {};
|
||||
const { id, pkgname, metalinkUrl, filename, origin } = download || {};
|
||||
|
||||
if (!id || !pkgname) {
|
||||
logger.warn("passed arguments missing id or pkgname");
|
||||
@@ -190,71 +192,27 @@ ipcMain.on("queue-install", async (event, download_json) => {
|
||||
const execParams = [];
|
||||
const downloadDir = `/tmp/spark-store/download/${pkgname}`;
|
||||
|
||||
// APM 应用:若本机没有 apm 命令,弹窗提示并可选提权安装 APM(安装后需重启电脑)
|
||||
// APM 应用:若本机没有 apm 命令,通知前端弹窗引导安装 APM
|
||||
if (origin === "apm") {
|
||||
const hasApm = await checkApmAvailable();
|
||||
if (!hasApm) {
|
||||
const win = BrowserWindow.fromWebContents(webContents);
|
||||
const { response } = await dialog.showMessageBox(win ?? undefined, {
|
||||
type: "question",
|
||||
title: "需要安装 APM",
|
||||
message: "此应用需要使用 APM 安装。",
|
||||
detail:
|
||||
"APM是星火应用商店的容器包管理器,安装APM后方可安装此应用,是否确认安装?",
|
||||
buttons: ["确认", "取消"],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
});
|
||||
if (response !== 0) {
|
||||
webContents.send("trigger-apm-install-dialog");
|
||||
webContents.send("install-complete", {
|
||||
id,
|
||||
success: false,
|
||||
time: Date.now(),
|
||||
exitCode: -1,
|
||||
message: JSON.stringify({
|
||||
message: "用户取消安装 APM,无法继续安装此应用",
|
||||
message: "未安装 APM,无法继续安装此应用",
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const installApmOk = await runInstallApm(superUserCmd);
|
||||
if (!installApmOk) {
|
||||
webContents.send("install-complete", {
|
||||
id,
|
||||
success: false,
|
||||
time: Date.now(),
|
||||
exitCode: -1,
|
||||
message: JSON.stringify({
|
||||
message: "安装 APM 失败,请检查网络或权限后重试",
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
}),
|
||||
});
|
||||
return;
|
||||
} else {
|
||||
// 安装APM成功,提示用户已安装成功,需要重启后方可展示应用
|
||||
await dialog.showMessageBox(win ?? undefined, {
|
||||
type: "info",
|
||||
title: "APM 安装成功",
|
||||
message: "恭喜您,APM 已成功安装",
|
||||
detail:
|
||||
"恭喜您,APM 已成功安装!您的应用已在安装中~\n首次安装APM后,需要重启电脑后方可在启动器展示应用。您可在应用安装完毕后择机重启电脑\n若您需要立即使用应用,可在应用安装后先在应用商店中打开您的应用。",
|
||||
buttons: ["确定"],
|
||||
defaultId: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (origin === "spark") {
|
||||
// Spark Store logic
|
||||
if (upgradeOnly) {
|
||||
execCommand = superUserCmd || SHELL_CALLER_PATH;
|
||||
if (superUserCmd) execParams.push(SHELL_CALLER_PATH);
|
||||
execParams.push("aptss", "install", "-y", pkgname, "--only-upgrade");
|
||||
} else {
|
||||
execCommand = superUserCmd || SHELL_CALLER_PATH;
|
||||
if (superUserCmd) execParams.push(SHELL_CALLER_PATH);
|
||||
|
||||
@@ -263,10 +221,16 @@ ipcMain.on("queue-install", async (event, download_json) => {
|
||||
"ssinstall",
|
||||
`${downloadDir}/${filename}`,
|
||||
"--delete-after-install",
|
||||
"--no-create-desktop-entry",
|
||||
"--native",
|
||||
);
|
||||
} else {
|
||||
execParams.push("aptss", "install", "-y", pkgname);
|
||||
}
|
||||
execParams.push(
|
||||
"ssinstall",
|
||||
pkgname,
|
||||
"--no-create-desktop-entry",
|
||||
"--native",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// APM Store logic
|
||||
@@ -422,6 +386,7 @@ async function processNextInQueue() {
|
||||
const aria2Args = [
|
||||
`--dir=${downloadDir}`,
|
||||
"--allow-overwrite=true",
|
||||
"--async-dns=false",
|
||||
"--summary-interval=1",
|
||||
"--connect-timeout=10",
|
||||
"--timeout=15",
|
||||
@@ -437,8 +402,10 @@ async function processNextInQueue() {
|
||||
|
||||
sendStatus("downloading");
|
||||
|
||||
// 下载重试逻辑:每次超时时间递增,最多3次
|
||||
const timeoutList = [3000, 5000, 15000]; // 第一次3秒,第二次5秒,第三次15秒
|
||||
// 下载重试逻辑:共10次,5次3秒,3次5秒,2次10秒
|
||||
const timeoutList = [
|
||||
3000, 3000, 3000, 3000, 3000, 5000, 5000, 5000, 10000, 10000,
|
||||
];
|
||||
let retryCount = 0;
|
||||
let downloadSuccess = false;
|
||||
|
||||
@@ -700,31 +667,26 @@ ipcMain.handle("check-installed", async (_event, payload: any) => {
|
||||
return isInstalled;
|
||||
}
|
||||
|
||||
const checkScript = "/opt/spark-store/extras/check-is-installed";
|
||||
// Spark: 使用 dpkg-query 检查安装状态
|
||||
const { code, stdout } = await runCommandCapture("dpkg-query", [
|
||||
"-W",
|
||||
"-f=${Package}\\t${Status}\\n",
|
||||
pkgname,
|
||||
]);
|
||||
|
||||
// 首先尝试使用内置脚本
|
||||
if (fs.existsSync(checkScript)) {
|
||||
const child = spawn(checkScript, [pkgname], {
|
||||
shell: false,
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
child.on("error", (err) => {
|
||||
logger.error(`check-installed 脚本执行失败: ${err?.message || err}`);
|
||||
resolve();
|
||||
});
|
||||
|
||||
child.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
const line = stdout.trim();
|
||||
if (line) {
|
||||
const parts = line.split("\t");
|
||||
if (parts.length >= 2) {
|
||||
const status = parts[1].trim();
|
||||
// 检查状态是否为 "install ok installed"
|
||||
if (status === "install ok installed") {
|
||||
isInstalled = true;
|
||||
logger.info(`应用已安装 (脚本检测): ${pkgname}`);
|
||||
logger.info(`应用已安装 (dpkg检测): ${pkgname}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
if (isInstalled) return true;
|
||||
}
|
||||
|
||||
return isInstalled;
|
||||
@@ -794,9 +756,38 @@ ipcMain.on("remove-installed", async (_event, payload) => {
|
||||
|
||||
ipcMain.handle(
|
||||
"list-installed",
|
||||
async (_event, origin: "apm" | "spark" = "apm") => {
|
||||
async (
|
||||
_event,
|
||||
payload: { origin: "apm" | "spark"; pkgnameList?: string[] },
|
||||
) => {
|
||||
const { origin, pkgnameList } = payload;
|
||||
const storeFilter = getStoreFilterFromArgv();
|
||||
const apmBasePath = "/var/lib/apm/apm/files/ace-env/var/lib/apm";
|
||||
|
||||
if (!isOriginEnabled(storeFilter, origin)) {
|
||||
return {
|
||||
success: false,
|
||||
message: `${origin} origin disabled by startup filter`,
|
||||
apps: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (origin === "spark" && !(await checkSparkAvailable())) {
|
||||
return {
|
||||
success: false,
|
||||
message: "spark origin unavailable on this system",
|
||||
apps: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (origin === "apm" && !(await checkApmAvailable())) {
|
||||
return {
|
||||
success: false,
|
||||
message: "apm origin unavailable on this system",
|
||||
apps: [],
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const installedApps: Array<{
|
||||
pkgname: string;
|
||||
@@ -810,9 +801,54 @@ ipcMain.handle(
|
||||
}> = [];
|
||||
|
||||
if (origin === "spark") {
|
||||
// 如果提供了包名列表,只检查这些包的安装状态(优化版)
|
||||
if (pkgnameList && pkgnameList.length > 0) {
|
||||
logger.info(
|
||||
`使用优化模式检查 ${pkgnameList.length} 个 Spark 包的安装状态`,
|
||||
);
|
||||
|
||||
// 批量查询这些包的状态
|
||||
// 注意:dpkg-query 在部分包不存在时也会返回非零码,但已找到的包会输出到 stdout
|
||||
const { stdout, stderr } = await runCommandCapture("dpkg-query", [
|
||||
"-W",
|
||||
"-f=${Package}\\t${Version}\\t${Architecture}\\t${Status}\\n",
|
||||
...pkgnameList,
|
||||
]);
|
||||
|
||||
// 即使没有错误,也可能有警告信息输出到 stderr
|
||||
if (stderr) {
|
||||
logger.debug(`dpkg-query warnings: ${stderr}`);
|
||||
}
|
||||
|
||||
const lines = stdout.split("\n");
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
const parts = trimmed.split("\t");
|
||||
if (parts.length >= 4) {
|
||||
const status = parts[3].trim();
|
||||
// 只保留状态为 "install ok installed" 的包
|
||||
if (status === "install ok installed") {
|
||||
installedApps.push({
|
||||
pkgname: parts[0],
|
||||
name: parts[0],
|
||||
version: parts[1],
|
||||
arch: parts[2],
|
||||
flags: "[installed]",
|
||||
origin: "spark",
|
||||
isDependency: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return { success: true, apps: installedApps };
|
||||
}
|
||||
|
||||
// 回退到全量扫描模式(未提供包名列表时)
|
||||
logger.info("使用全量扫描模式获取所有 Spark 已安装包");
|
||||
const { code, stdout } = await runCommandCapture("dpkg-query", [
|
||||
"-W",
|
||||
"-f=${Package} ${Version} ${Architecture}\\n",
|
||||
"-f=${Package}\\t${Version}\\t${Architecture}\\t${Status}\\n",
|
||||
]);
|
||||
|
||||
if (code !== 0) {
|
||||
@@ -828,8 +864,11 @@ ipcMain.handle(
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
const parts = trimmed.split(" ");
|
||||
if (parts.length >= 3) {
|
||||
const parts = trimmed.split("\t");
|
||||
if (parts.length >= 4) {
|
||||
const status = parts[3].trim();
|
||||
// 只保留状态为 "install ok installed" 的包
|
||||
if (status === "install ok installed") {
|
||||
installedApps.push({
|
||||
pkgname: parts[0],
|
||||
name: parts[0],
|
||||
@@ -841,6 +880,7 @@ ipcMain.handle(
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return { success: true, apps: installedApps };
|
||||
}
|
||||
|
||||
@@ -986,52 +1026,16 @@ ipcMain.handle("check-apm-available", async () => {
|
||||
return await checkApmAvailable();
|
||||
});
|
||||
|
||||
ipcMain.handle("check-spark-available", async () => {
|
||||
return await checkSparkAvailable();
|
||||
});
|
||||
|
||||
// 显示 APM 安装对话框(在点击安装按钮时提前检查)
|
||||
// 前端已改为 Vue 弹窗,此后端处理仅作为兜底
|
||||
ipcMain.handle("show-apm-install-dialog", async (event) => {
|
||||
const webContents = event.sender;
|
||||
const win = BrowserWindow.fromWebContents(webContents);
|
||||
const superUserCmd = await checkSuperUserCommand();
|
||||
|
||||
const { response } = await dialog.showMessageBox(win ?? undefined, {
|
||||
type: "question",
|
||||
title: "需要安装 APM",
|
||||
message: "此应用需要使用 APM 安装。",
|
||||
detail:
|
||||
"APM 是星火应用商店的软件包兼容工具,此应用使用星火 APM 提供支持,安装APM后方可安装此应用,是否确认安装?",
|
||||
buttons: ["确认", "取消"],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
});
|
||||
|
||||
if (response !== 0) {
|
||||
webContents.send("trigger-apm-install-dialog");
|
||||
return { success: false, cancelled: true };
|
||||
}
|
||||
|
||||
const installApmOk = await runInstallApm(superUserCmd);
|
||||
if (!installApmOk) {
|
||||
await dialog.showMessageBox(win ?? undefined, {
|
||||
type: "error",
|
||||
title: "安装失败",
|
||||
message: "安装 APM 失败",
|
||||
detail: "请检查网络或权限后重试",
|
||||
buttons: ["确定"],
|
||||
defaultId: 0,
|
||||
});
|
||||
return { success: false, cancelled: false };
|
||||
}
|
||||
|
||||
// 安装APM成功,提示用户已安装成功,需要重启后方可展示应用
|
||||
await dialog.showMessageBox(win ?? undefined, {
|
||||
type: "info",
|
||||
title: "APM 安装成功",
|
||||
message: "恭喜您,APM 已成功安装",
|
||||
detail:
|
||||
"恭喜您,APM 已成功安装!\n首次安装APM后,需要重启电脑后方可使用全部功能。您可在应用安装完毕后择机重启电脑。",
|
||||
buttons: ["确定"],
|
||||
defaultId: 0,
|
||||
});
|
||||
|
||||
return { success: true, cancelled: false };
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
/**
|
||||
* 共享的安装/下载逻辑
|
||||
* 被 install-manager.ts 和 update-center 共同使用
|
||||
*/
|
||||
import { spawn } from "node:child_process";
|
||||
import { createWriteStream } from "node:fs";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import axios from "axios";
|
||||
import pino from "pino";
|
||||
import { findExecutable, SUPER_USER_COMMAND_CANDIDATES } from "./superuser";
|
||||
|
||||
const logger = pino({ name: "shared-installer" });
|
||||
|
||||
export const SHELL_CALLER_PATH = "/opt/spark-store/extras/shell-caller.sh";
|
||||
|
||||
export interface DownloadOptions {
|
||||
pkgname: string;
|
||||
metalinkUrl: string;
|
||||
filename: string;
|
||||
downloadDir: string;
|
||||
onLog?: (msg: string) => void;
|
||||
onProgress?: (progress: number) => void;
|
||||
onStatus?: (status: string) => void;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface DownloadResult {
|
||||
filePath: string;
|
||||
downloadDir: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载 metalink 文件并使用 aria2c 下载 deb 包
|
||||
* 与 install-manager.ts 中的下载逻辑保持一致
|
||||
*/
|
||||
export const downloadPackage = async ({
|
||||
metalinkUrl,
|
||||
filename,
|
||||
downloadDir,
|
||||
onLog,
|
||||
onProgress,
|
||||
onStatus,
|
||||
signal,
|
||||
}: DownloadOptions): Promise<DownloadResult> => {
|
||||
// 1. 创建下载目录
|
||||
try {
|
||||
if (!fs.existsSync(downloadDir)) {
|
||||
fs.mkdirSync(downloadDir, { recursive: true });
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`无法创建目录 ${downloadDir}: ${err}`);
|
||||
throw err;
|
||||
}
|
||||
|
||||
const metalinkPath = path.join(downloadDir, `${filename}.metalink`);
|
||||
|
||||
onLog?.(`正在获取 Metalink 文件: ${metalinkUrl}`);
|
||||
|
||||
// 2. 下载 metalink 文件
|
||||
const response = await axios.get(metalinkUrl, {
|
||||
baseURL: "https://erotica.spark-app.store",
|
||||
responseType: "stream",
|
||||
});
|
||||
|
||||
const writer = createWriteStream(metalinkPath);
|
||||
response.data.pipe(writer);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
writer.on("finish", resolve);
|
||||
writer.on("error", reject);
|
||||
});
|
||||
|
||||
onLog?.("Metalink 文件下载完成");
|
||||
|
||||
// 3. 清理下载目录中的旧文件(保留 .metalink 文件)
|
||||
const existingFiles = fs.readdirSync(downloadDir);
|
||||
for (const file of existingFiles) {
|
||||
if (file.endsWith(".metalink")) continue;
|
||||
const filePath = path.join(downloadDir, file);
|
||||
try {
|
||||
fs.unlinkSync(filePath);
|
||||
onLog?.(`已清理旧文件: ${file}`);
|
||||
} catch (err) {
|
||||
logger.warn(`清理文件失败 ${filePath}: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 使用 aria2c 下载 deb 文件
|
||||
const aria2Args = [
|
||||
`--dir=${downloadDir}`,
|
||||
"--allow-overwrite=true",
|
||||
"--async-dns=false",
|
||||
"--summary-interval=1",
|
||||
"--connect-timeout=10",
|
||||
"--timeout=15",
|
||||
"--max-tries=3",
|
||||
"--retry-wait=5",
|
||||
"--max-concurrent-downloads=4",
|
||||
"--min-split-size=1M",
|
||||
"--lowest-speed-limit=1K",
|
||||
"--auto-file-renaming=false",
|
||||
"-M",
|
||||
metalinkPath,
|
||||
];
|
||||
|
||||
onStatus?.("downloading");
|
||||
|
||||
// 下载重试逻辑:共10次,5次3秒,3次5秒,2次10秒
|
||||
const timeoutList = [
|
||||
3000, 3000, 3000, 3000, 3000, 5000, 5000, 5000, 10000, 10000,
|
||||
];
|
||||
let retryCount = 0;
|
||||
let downloadSuccess = false;
|
||||
|
||||
while (retryCount < timeoutList.length && !downloadSuccess) {
|
||||
const currentTimeout = timeoutList[retryCount];
|
||||
|
||||
if (retryCount > 0) {
|
||||
onLog?.(`第 ${retryCount} 次重试下载...`);
|
||||
onProgress?.(0);
|
||||
// 重试前清理旧文件
|
||||
const retryFiles = fs.readdirSync(downloadDir);
|
||||
for (const file of retryFiles) {
|
||||
if (file.endsWith(".metalink")) continue;
|
||||
const filePath = path.join(downloadDir, file);
|
||||
try {
|
||||
fs.unlinkSync(filePath);
|
||||
} catch (cleanErr) {
|
||||
logger.warn(`重试清理文件失败 ${filePath}: ${cleanErr}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
onLog?.(`启动下载: aria2c ${aria2Args.join(" ")}`);
|
||||
const child = spawn("aria2c", aria2Args);
|
||||
|
||||
let lastProgressTime = Date.now();
|
||||
let lastProgress = 0;
|
||||
const progressCheckInterval = 1000;
|
||||
|
||||
// 设置超时检测定时器
|
||||
const timeoutChecker = setInterval(() => {
|
||||
const now = Date.now();
|
||||
// 只在进度为0时检查超时
|
||||
if (lastProgress === 0 && now - lastProgressTime > currentTimeout) {
|
||||
clearInterval(timeoutChecker);
|
||||
child.kill();
|
||||
reject(new Error(`下载卡在0%超过 ${currentTimeout / 1000} 秒`));
|
||||
}
|
||||
}, progressCheckInterval);
|
||||
|
||||
child.stdout.on("data", (data) => {
|
||||
const str = data.toString();
|
||||
// Match ( 12%) or (12%)
|
||||
const match = str.match(/[0-9]+(\.[0-9]+)?%/g);
|
||||
if (match) {
|
||||
const p = parseFloat(match.at(-1)) / 100;
|
||||
if (p > lastProgress) {
|
||||
lastProgress = p;
|
||||
lastProgressTime = Date.now();
|
||||
}
|
||||
onProgress?.(p);
|
||||
}
|
||||
});
|
||||
child.stderr.on("data", (d) => onLog?.(`aria2c: ${d}`));
|
||||
|
||||
// 处理取消信号
|
||||
const abortHandler = () => {
|
||||
clearInterval(timeoutChecker);
|
||||
child.kill();
|
||||
reject(new Error("下载已取消"));
|
||||
};
|
||||
|
||||
signal?.addEventListener("abort", abortHandler, { once: true });
|
||||
|
||||
child.on("close", (code) => {
|
||||
clearInterval(timeoutChecker);
|
||||
signal?.removeEventListener("abort", abortHandler);
|
||||
|
||||
if (code === 0) {
|
||||
onProgress?.(1);
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Aria2c exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
child.on("error", (err) => {
|
||||
clearInterval(timeoutChecker);
|
||||
signal?.removeEventListener("abort", abortHandler);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
|
||||
// 检查是否已取消
|
||||
if (signal?.aborted) {
|
||||
throw new Error("下载已取消");
|
||||
}
|
||||
downloadSuccess = true;
|
||||
} catch (err) {
|
||||
retryCount++;
|
||||
if (retryCount >= timeoutList.length) {
|
||||
throw new Error(`下载失败,已重试 ${timeoutList.length} 次: ${err}`);
|
||||
}
|
||||
onLog?.(`下载失败,准备重试 (${retryCount}/${timeoutList.length})`);
|
||||
// 等待2秒后重试
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
}
|
||||
}
|
||||
|
||||
const filePath = path.join(downloadDir, filename);
|
||||
return { filePath, downloadDir };
|
||||
};
|
||||
|
||||
export interface InstallOptions {
|
||||
pkgname: string;
|
||||
filePath: string;
|
||||
origin: "spark" | "apm";
|
||||
superUserCmd?: string;
|
||||
onLog?: (msg: string) => void;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
* 安装已下载的包
|
||||
* 与 install-manager.ts 中的安装逻辑保持一致
|
||||
*/
|
||||
export const installPackage = async ({
|
||||
filePath,
|
||||
origin,
|
||||
superUserCmd,
|
||||
onLog,
|
||||
signal,
|
||||
}: InstallOptions): Promise<void> => {
|
||||
// 构建安装命令
|
||||
let execCommand = "";
|
||||
const execParams: string[] = [];
|
||||
|
||||
if (origin === "spark") {
|
||||
execCommand = superUserCmd || SHELL_CALLER_PATH;
|
||||
if (superUserCmd) execParams.push(SHELL_CALLER_PATH);
|
||||
execParams.push(
|
||||
"ssinstall",
|
||||
filePath,
|
||||
"--delete-after-install",
|
||||
"--no-create-desktop-entry",
|
||||
"--native",
|
||||
);
|
||||
} else {
|
||||
// APM
|
||||
execCommand = superUserCmd || SHELL_CALLER_PATH;
|
||||
if (superUserCmd) {
|
||||
execParams.push(SHELL_CALLER_PATH);
|
||||
}
|
||||
execParams.push("apm", "ssinstall", filePath);
|
||||
}
|
||||
|
||||
const cmdString = `${execCommand} ${execParams.join(" ")}`;
|
||||
onLog?.(`执行安装: ${cmdString}`);
|
||||
logger.info(`启动安装: ${cmdString}`);
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(execCommand, execParams, {
|
||||
shell: false,
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
let logBuffer = "";
|
||||
let logBufferTimer: NodeJS.Timeout | null = null;
|
||||
const LOG_FLUSH_MS = 100;
|
||||
|
||||
const flushLogBuffer = () => {
|
||||
if (logBuffer.length > 0) {
|
||||
onLog?.(logBuffer);
|
||||
logBuffer = "";
|
||||
}
|
||||
logBufferTimer = null;
|
||||
};
|
||||
|
||||
const bufferedSendLog = (message: string) => {
|
||||
logBuffer += message;
|
||||
if (!logBufferTimer) {
|
||||
logBufferTimer = setTimeout(flushLogBuffer, LOG_FLUSH_MS);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理取消信号
|
||||
const abortHandler = () => {
|
||||
child.kill();
|
||||
reject(new Error("安装已取消"));
|
||||
};
|
||||
|
||||
signal?.addEventListener("abort", abortHandler, { once: true });
|
||||
|
||||
child.stdout?.on("data", (data) => {
|
||||
bufferedSendLog(data.toString());
|
||||
});
|
||||
|
||||
child.stderr?.on("data", (data) => {
|
||||
bufferedSendLog(data.toString());
|
||||
});
|
||||
|
||||
child.on("error", (err) => {
|
||||
signal?.removeEventListener("abort", abortHandler);
|
||||
if (logBufferTimer) clearTimeout(logBufferTimer);
|
||||
flushLogBuffer();
|
||||
reject(err);
|
||||
});
|
||||
|
||||
child.on("close", (code) => {
|
||||
signal?.removeEventListener("abort", abortHandler);
|
||||
if (logBufferTimer) clearTimeout(logBufferTimer);
|
||||
flushLogBuffer();
|
||||
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`安装失败,退出码: ${code}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查是否有 apm 命令
|
||||
*/
|
||||
export const checkApmAvailable = async (): Promise<boolean> => {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn("which", ["apm"]);
|
||||
let stdout = "";
|
||||
child.stdout?.on("data", (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
resolve(code === 0 && stdout.trim().length > 0);
|
||||
});
|
||||
child.on("error", () => {
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查提权命令
|
||||
*/
|
||||
export const checkSuperUserCommand = async (): Promise<string> => {
|
||||
if (process.getuid?.() === 0) return "";
|
||||
|
||||
for (const command of SUPER_USER_COMMAND_CANDIDATES) {
|
||||
const superUserCmd = await findExecutable(command);
|
||||
if (superUserCmd.length > 0) {
|
||||
logger.info(`找到提升权限命令: ${superUserCmd}`);
|
||||
return superUserCmd;
|
||||
}
|
||||
}
|
||||
|
||||
logger.error("没有找到提升权限的命令 pkexec!");
|
||||
return "";
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export const SUPER_USER_COMMAND_CANDIDATES = [
|
||||
"/usr/bin/pkexec",
|
||||
"/run/wrappers/bin/pkexec",
|
||||
];
|
||||
|
||||
const WHICH_TIMEOUT_MS = 5000;
|
||||
|
||||
export const findExecutable = async (command: string): Promise<string> => {
|
||||
if (path.isAbsolute(command)) {
|
||||
try {
|
||||
await fs.promises.access(command, fs.constants.X_OK);
|
||||
return command;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
return await new Promise<string>((resolve) => {
|
||||
const child = spawn("which", [command]);
|
||||
let stdout = "";
|
||||
let settled = false;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
child.kill();
|
||||
finish("");
|
||||
}, WHICH_TIMEOUT_MS);
|
||||
|
||||
function finish(result: string) {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve(result);
|
||||
}
|
||||
|
||||
child.stdout?.on("data", (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
finish(code === 0 ? stdout.trim() : "");
|
||||
});
|
||||
child.on("error", () => {
|
||||
finish("");
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -1,7 +1,4 @@
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
import { downloadPackage } from "../shared-installer";
|
||||
import type { UpdateCenterItem } from "./types";
|
||||
|
||||
export interface Aria2DownloadResult {
|
||||
@@ -16,8 +13,6 @@ export interface RunAria2DownloadOptions {
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
const PROGRESS_PATTERN = /(\d{1,3}(?:\.\d+)?)%/;
|
||||
|
||||
export const runAria2Download = async ({
|
||||
item,
|
||||
downloadDir,
|
||||
@@ -29,59 +24,18 @@ export const runAria2Download = async ({
|
||||
throw new Error(`Missing download metadata for ${item.pkgname}`);
|
||||
}
|
||||
|
||||
await mkdir(downloadDir, { recursive: true });
|
||||
// 使用与商店安装相同的下载逻辑
|
||||
const metalinkUrl = `${item.downloadUrl}.metalink`;
|
||||
|
||||
const filePath = join(downloadDir, item.fileName);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn("aria2c", [
|
||||
"--dir",
|
||||
const result = await downloadPackage({
|
||||
pkgname: item.pkgname,
|
||||
metalinkUrl,
|
||||
filename: item.fileName,
|
||||
downloadDir,
|
||||
"--out",
|
||||
item.fileName,
|
||||
item.downloadUrl,
|
||||
]);
|
||||
|
||||
const abortDownload = () => {
|
||||
child.kill();
|
||||
reject(new Error(`Update task cancelled: ${item.pkgname}`));
|
||||
};
|
||||
|
||||
if (signal?.aborted) {
|
||||
abortDownload();
|
||||
return;
|
||||
}
|
||||
|
||||
signal?.addEventListener("abort", abortDownload, { once: true });
|
||||
|
||||
const handleOutput = (chunk: Buffer) => {
|
||||
const message = chunk.toString().trim();
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
|
||||
onLog?.(message);
|
||||
const progressMatch = message.match(PROGRESS_PATTERN);
|
||||
if (progressMatch) {
|
||||
onProgress?.(Number(progressMatch[1]));
|
||||
}
|
||||
};
|
||||
|
||||
child.stdout?.on("data", handleOutput);
|
||||
child.stderr?.on("data", handleOutput);
|
||||
child.on("error", reject);
|
||||
child.on("close", (code) => {
|
||||
signal?.removeEventListener("abort", abortDownload);
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error(`aria2c exited with code ${code ?? -1}`));
|
||||
});
|
||||
onLog,
|
||||
onProgress,
|
||||
signal,
|
||||
});
|
||||
|
||||
onProgress?.(100);
|
||||
|
||||
return { filePath };
|
||||
return { filePath: result.filePath };
|
||||
};
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname } from "node:path";
|
||||
import { join } from "node:path";
|
||||
|
||||
import type { UpdateCenterItem } from "./types";
|
||||
|
||||
export const LEGACY_IGNORE_CONFIG_PATH = "/etc/spark-store/ignored_apps.conf";
|
||||
export const IGNORE_CONFIG_PATH = join(
|
||||
homedir(),
|
||||
".config",
|
||||
"spark-store",
|
||||
"ignored_apps.conf",
|
||||
);
|
||||
|
||||
const LEGACY_IGNORE_SEPARATOR = "|";
|
||||
|
||||
@@ -77,3 +84,15 @@ export const applyIgnoredEntries = (
|
||||
createIgnoreKey(item.pkgname, item.nextVersion),
|
||||
),
|
||||
}));
|
||||
|
||||
export const sortIgnoredItems = (
|
||||
items: UpdateCenterItem[],
|
||||
): UpdateCenterItem[] => {
|
||||
return [...items].sort((left, right) => {
|
||||
if (left.ignored === right.ignored) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return left.ignored === true ? 1 : -1;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -12,8 +12,10 @@ import {
|
||||
import { resolveUpdateItemIcons } from "./icons";
|
||||
import {
|
||||
createUpdateCenterService,
|
||||
type StoreFilter,
|
||||
type UpdateCenterIgnorePayload,
|
||||
type UpdateCenterService,
|
||||
type UpdateCenterStartTask,
|
||||
} from "./service";
|
||||
import type { UpdateCenterItem } from "./types";
|
||||
|
||||
@@ -33,20 +35,26 @@ export interface UpdateCenterLoadItemsResult {
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
type StoreCategoryMap = Map<string, string>;
|
||||
interface RemoteAppMetadata {
|
||||
category: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
type StoreAppMetadataMap = Map<string, RemoteAppMetadata>;
|
||||
|
||||
interface RemoteCategoryAppEntry {
|
||||
Name?: string;
|
||||
Pkgname?: string;
|
||||
}
|
||||
|
||||
const REMOTE_STORE_BASE_URL = "https://erotica.spark-app.store";
|
||||
const categoryCache = new Map<string, Promise<StoreCategoryMap>>();
|
||||
const categoryCache = new Map<string, Promise<StoreAppMetadataMap>>();
|
||||
|
||||
const APTSS_LIST_UPGRADABLE_COMMAND = {
|
||||
command: "bash",
|
||||
args: [
|
||||
"-lc",
|
||||
"env LANGUAGE=en_US /usr/bin/apt -c /opt/durapps/spark-store/bin/apt-fast-conf/aptss-apt.conf list --upgradable -o Dir::Etc::sourcelist=/opt/durapps/spark-store/bin/apt-fast-conf/sources.list.d/aptss.list -o Dir::Etc::sourceparts=/dev/null -o APT::Get::List-Cleanup=0",
|
||||
"env LANGUAGE=en_US /usr/bin/apt -c /opt/durapps/spark-store/bin/apt-fast-conf/aptss-apt.conf list --upgradable -o Dir::Etc::sourcelist=/opt/durapps/spark-store/bin/apt-fast-conf/sources.list.d/aptss.list -o Dir::Etc::sourceparts=/dev/null -o APT::Get::List-Cleanup=0 | awk 'NR>1'",
|
||||
],
|
||||
};
|
||||
|
||||
@@ -66,6 +74,14 @@ const getApmPrintUrisCommand = (pkgname: string) => ({
|
||||
],
|
||||
});
|
||||
|
||||
const getAptssPrintUrisCommand = (pkgname: string) => ({
|
||||
command: "bash",
|
||||
args: [
|
||||
"-lc",
|
||||
`/usr/bin/apt download ${pkgname} --print-uris -c /opt/durapps/spark-store/bin/apt-fast-conf/aptss-apt.conf -o Dir::Etc::sourcelist=/opt/durapps/spark-store/bin/apt-fast-conf/sources.list.d/aptss.list -o Dir::Etc::sourceparts=/dev/null`,
|
||||
],
|
||||
});
|
||||
|
||||
const runCommandCapture: UpdateCenterCommandRunner = async (
|
||||
command,
|
||||
args,
|
||||
@@ -140,6 +156,65 @@ const loadApmItemMetadata = async (
|
||||
};
|
||||
};
|
||||
|
||||
const loadAptssItemMetadata = async (
|
||||
item: UpdateCenterItem,
|
||||
runCommand: UpdateCenterCommandRunner,
|
||||
): Promise<
|
||||
| { item: UpdateCenterItem; warning?: undefined }
|
||||
| { item: null; warning: string }
|
||||
> => {
|
||||
console.log(`[DEBUG] Loading APTSS metadata for ${item.pkgname}`);
|
||||
const printUrisCommand = getAptssPrintUrisCommand(item.pkgname);
|
||||
console.log(
|
||||
`[DEBUG] APTSS command: ${printUrisCommand.command} ${printUrisCommand.args.join(" ")}`,
|
||||
);
|
||||
|
||||
const metadataResult = await runCommand(
|
||||
printUrisCommand.command,
|
||||
printUrisCommand.args,
|
||||
);
|
||||
console.log(`[DEBUG] APTSS metadata result code: ${metadataResult.code}`);
|
||||
console.log(
|
||||
`[DEBUG] APTSS metadata stdout: ${metadataResult.stdout.substring(0, 500)}`,
|
||||
);
|
||||
console.log(
|
||||
`[DEBUG] APTSS metadata stderr: ${metadataResult.stderr.substring(0, 500)}`,
|
||||
);
|
||||
|
||||
const commandError = getCommandError(
|
||||
`aptss metadata query for ${item.pkgname}`,
|
||||
metadataResult,
|
||||
);
|
||||
if (commandError) {
|
||||
console.log(`[DEBUG] APTSS metadata error: ${commandError}`);
|
||||
return { item: null, warning: commandError };
|
||||
}
|
||||
|
||||
const metadata = parsePrintUrisOutput(metadataResult.stdout);
|
||||
if (metadata) {
|
||||
console.log(`[DEBUG] APTSS parsed metadata:`, {
|
||||
...metadata,
|
||||
downloadUrl: `${metadata.downloadUrl}.metalink`,
|
||||
});
|
||||
} else {
|
||||
console.log(`[DEBUG] APTSS parsed metadata:`, metadata);
|
||||
}
|
||||
|
||||
if (!metadata) {
|
||||
return {
|
||||
item: null,
|
||||
warning: `aptss metadata query for ${item.pkgname} returned no package metadata`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
item: {
|
||||
...item,
|
||||
...metadata,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const enrichApmItems = async (
|
||||
items: UpdateCenterItem[],
|
||||
runCommand: UpdateCenterCommandRunner,
|
||||
@@ -156,6 +231,22 @@ const enrichApmItems = async (
|
||||
};
|
||||
};
|
||||
|
||||
const enrichAptssItems = async (
|
||||
items: UpdateCenterItem[],
|
||||
runCommand: UpdateCenterCommandRunner,
|
||||
): Promise<UpdateCenterLoadItemsResult> => {
|
||||
const results = await Promise.all(
|
||||
items.map((item) => loadAptssItemMetadata(item, runCommand)),
|
||||
);
|
||||
|
||||
return {
|
||||
items: results.flatMap((result) => (result.item ? [result.item] : [])),
|
||||
warnings: results.flatMap((result) =>
|
||||
result.warning ? [result.warning] : [],
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
const getStoreArch = (
|
||||
item: Pick<UpdateCenterItem, "source" | "arch">,
|
||||
): string => {
|
||||
@@ -182,7 +273,7 @@ const loadJson = async <T>(url: string): Promise<T> => {
|
||||
|
||||
const loadStoreCategoryMap = async (
|
||||
storeArch: string,
|
||||
): Promise<StoreCategoryMap> => {
|
||||
): Promise<StoreAppMetadataMap> => {
|
||||
const categories = await loadJson<Record<string, unknown>>(
|
||||
`${REMOTE_STORE_BASE_URL}/${storeArch}/categories.json`,
|
||||
);
|
||||
@@ -196,7 +287,7 @@ const loadStoreCategoryMap = async (
|
||||
}),
|
||||
);
|
||||
|
||||
const categoryMap: StoreCategoryMap = new Map();
|
||||
const categoryMap: StoreAppMetadataMap = new Map();
|
||||
for (const entry of categoryEntries) {
|
||||
if (entry.status !== "fulfilled") {
|
||||
continue;
|
||||
@@ -204,7 +295,10 @@ const loadStoreCategoryMap = async (
|
||||
|
||||
for (const app of entry.value.apps) {
|
||||
if (app.Pkgname && !categoryMap.has(app.Pkgname)) {
|
||||
categoryMap.set(app.Pkgname, entry.value.category);
|
||||
categoryMap.set(app.Pkgname, {
|
||||
category: entry.value.category,
|
||||
name: app.Name,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -212,7 +306,9 @@ const loadStoreCategoryMap = async (
|
||||
return categoryMap;
|
||||
};
|
||||
|
||||
const getStoreCategoryMap = (storeArch: string): Promise<StoreCategoryMap> => {
|
||||
const getStoreCategoryMap = (
|
||||
storeArch: string,
|
||||
): Promise<StoreAppMetadataMap> => {
|
||||
const cached = categoryCache.get(storeArch);
|
||||
if (cached) {
|
||||
return cached;
|
||||
@@ -241,8 +337,14 @@ const enrichItemCategories = async (
|
||||
}
|
||||
|
||||
const categoryMap = await getStoreCategoryMap(storeArch);
|
||||
const category = categoryMap.get(item.pkgname);
|
||||
return category ? { ...item, category } : item;
|
||||
const metadata = categoryMap.get(item.pkgname);
|
||||
return metadata
|
||||
? {
|
||||
...item,
|
||||
category: metadata.category,
|
||||
...(metadata.name ? { name: metadata.name } : {}),
|
||||
}
|
||||
: item;
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -255,62 +357,156 @@ const enrichItemIcons = (items: UpdateCenterItem[]): UpdateCenterItem[] => {
|
||||
});
|
||||
};
|
||||
|
||||
const isSourceEnabled = (
|
||||
storeFilter: StoreFilter,
|
||||
source: "spark" | "apm",
|
||||
): boolean => {
|
||||
return storeFilter === "both" || storeFilter === source;
|
||||
};
|
||||
|
||||
const isCommandAvailable = async (
|
||||
runCommand: UpdateCenterCommandRunner,
|
||||
command: "aptss" | "apm",
|
||||
): Promise<boolean> => {
|
||||
const result = await runCommand("which", [command]);
|
||||
return result.code === 0 && result.stdout.trim().length > 0;
|
||||
};
|
||||
|
||||
export const loadUpdateCenterItems = async (
|
||||
storeFilter: StoreFilter = "both",
|
||||
runCommand: UpdateCenterCommandRunner = runCommandCapture,
|
||||
): Promise<UpdateCenterLoadItemsResult> => {
|
||||
console.log(
|
||||
`[UpdateCenter] loadUpdateCenterItems called with storeFilter=${storeFilter}`,
|
||||
);
|
||||
const [sparkEnabled, apmEnabled] = await Promise.all([
|
||||
isSourceEnabled(storeFilter, "spark")
|
||||
? isCommandAvailable(runCommand, "aptss")
|
||||
: Promise.resolve(false),
|
||||
isSourceEnabled(storeFilter, "apm")
|
||||
? isCommandAvailable(runCommand, "apm")
|
||||
: Promise.resolve(false),
|
||||
]);
|
||||
console.log(
|
||||
`[UpdateCenter] sparkEnabled=${sparkEnabled}, apmEnabled=${apmEnabled}`,
|
||||
);
|
||||
|
||||
const [aptssResult, apmResult, aptssInstalledResult, apmInstalledResult] =
|
||||
await Promise.all([
|
||||
runCommand(
|
||||
sparkEnabled
|
||||
? runCommand(
|
||||
APTSS_LIST_UPGRADABLE_COMMAND.command,
|
||||
APTSS_LIST_UPGRADABLE_COMMAND.args,
|
||||
),
|
||||
runCommand("apm", ["list", "--upgradable"]),
|
||||
runCommand(
|
||||
)
|
||||
: Promise.resolve({ code: 0, stdout: "", stderr: "" }),
|
||||
apmEnabled
|
||||
? runCommand("apm", ["list", "--upgradable"])
|
||||
: Promise.resolve({ code: 0, stdout: "", stderr: "" }),
|
||||
sparkEnabled
|
||||
? runCommand(
|
||||
DPKG_QUERY_INSTALLED_COMMAND.command,
|
||||
DPKG_QUERY_INSTALLED_COMMAND.args,
|
||||
),
|
||||
runCommand("apm", ["list", "--installed"]),
|
||||
)
|
||||
: Promise.resolve({ code: 0, stdout: "", stderr: "" }),
|
||||
apmEnabled
|
||||
? runCommand("apm", ["list", "--installed"])
|
||||
: Promise.resolve({ code: 0, stdout: "", stderr: "" }),
|
||||
]);
|
||||
|
||||
console.log(
|
||||
`[UpdateCenter] aptssResult: code=${aptssResult.code}, stdout=${aptssResult.stdout.substring(0, 500)}, stderr=${aptssResult.stderr.substring(0, 500)}`,
|
||||
);
|
||||
console.log(
|
||||
`[UpdateCenter] apmResult: code=${apmResult.code}, stdout=${apmResult.stdout.substring(0, 500)}, stderr=${apmResult.stderr.substring(0, 500)}`,
|
||||
);
|
||||
console.log(
|
||||
`[UpdateCenter] aptssInstalledResult: code=${aptssInstalledResult.code}, stdout=${aptssInstalledResult.stdout.substring(0, 500)}`,
|
||||
);
|
||||
console.log(
|
||||
`[UpdateCenter] apmInstalledResult: code=${apmInstalledResult.code}, stdout=${apmInstalledResult.stdout.substring(0, 500)}`,
|
||||
);
|
||||
|
||||
const aptssAvailable =
|
||||
sparkEnabled && (aptssResult.code === 0 || aptssInstalledResult.code === 0);
|
||||
|
||||
const warnings = [
|
||||
getCommandError("aptss upgradable query", aptssResult),
|
||||
getCommandError("apm upgradable query", apmResult),
|
||||
getCommandError("dpkg installed query", aptssInstalledResult),
|
||||
getCommandError("apm installed query", apmInstalledResult),
|
||||
aptssAvailable
|
||||
? getCommandError("aptss upgradable query", aptssResult)
|
||||
: null,
|
||||
apmEnabled ? getCommandError("apm upgradable query", apmResult) : null,
|
||||
aptssAvailable
|
||||
? getCommandError("dpkg installed query", aptssInstalledResult)
|
||||
: null,
|
||||
apmEnabled
|
||||
? getCommandError("apm installed query", apmInstalledResult)
|
||||
: null,
|
||||
].filter((message): message is string => message !== null);
|
||||
|
||||
const aptssItems =
|
||||
aptssResult.code === 0
|
||||
aptssAvailable && aptssResult.code === 0
|
||||
? parseAptssUpgradableOutput(aptssResult.stdout)
|
||||
: [];
|
||||
const apmItems =
|
||||
apmResult.code === 0 ? parseApmUpgradableOutput(apmResult.stdout) : [];
|
||||
|
||||
if (aptssResult.code !== 0 && apmResult.code !== 0) {
|
||||
throw new Error(warnings.join("; "));
|
||||
}
|
||||
|
||||
const installedSources = buildInstalledSourceMap(
|
||||
aptssInstalledResult.code === 0 ? aptssInstalledResult.stdout : "",
|
||||
apmInstalledResult.code === 0 ? apmInstalledResult.stdout : "",
|
||||
apmEnabled && apmResult.code === 0
|
||||
? parseApmUpgradableOutput(apmResult.stdout)
|
||||
: [];
|
||||
console.log(
|
||||
`[UpdateCenter] parsed aptssItems count=${aptssItems.length}`,
|
||||
aptssItems.map((i) => `${i.pkgname} ${i.currentVersion}->${i.nextVersion}`),
|
||||
);
|
||||
console.log(
|
||||
`[UpdateCenter] parsed apmItems count=${apmItems.length}`,
|
||||
apmItems.map((i) => `${i.pkgname} ${i.currentVersion}->${i.nextVersion}`),
|
||||
);
|
||||
|
||||
const installedSources = buildInstalledSourceMap(
|
||||
aptssAvailable && aptssInstalledResult.code === 0
|
||||
? aptssInstalledResult.stdout
|
||||
: "",
|
||||
apmInstalledResult.code === 0 ? apmInstalledResult.stdout : "",
|
||||
);
|
||||
console.log(`[UpdateCenter] installedSources size=${installedSources.size}`);
|
||||
|
||||
const [categorizedAptssItems, categorizedApmItems] = await Promise.all([
|
||||
enrichItemCategories(aptssItems),
|
||||
enrichItemCategories(apmItems),
|
||||
aptssAvailable ? enrichItemCategories(aptssItems) : Promise.resolve([]),
|
||||
apmEnabled ? enrichItemCategories(apmItems) : Promise.resolve([]),
|
||||
]);
|
||||
const enrichedApmItems = await enrichApmItems(
|
||||
categorizedApmItems,
|
||||
runCommand,
|
||||
const [enrichedAptssItems, enrichedApmItems] = await Promise.all([
|
||||
aptssAvailable
|
||||
? enrichAptssItems(categorizedAptssItems, runCommand)
|
||||
: Promise.resolve({ items: [], warnings: [] }),
|
||||
apmEnabled
|
||||
? enrichApmItems(categorizedApmItems, runCommand)
|
||||
: Promise.resolve({ items: [], warnings: [] }),
|
||||
]);
|
||||
console.log(
|
||||
`[UpdateCenter] enrichedAptssItems: count=${enrichedAptssItems.items.length}, warnings=${enrichedAptssItems.warnings.length}`,
|
||||
enrichedAptssItems.warnings,
|
||||
);
|
||||
console.log(
|
||||
`[UpdateCenter] enrichedApmItems: count=${enrichedApmItems.items.length}, warnings=${enrichedApmItems.warnings.length}`,
|
||||
enrichedApmItems.warnings,
|
||||
);
|
||||
|
||||
const mergedItems = mergeUpdateSources(
|
||||
enrichItemIcons(enrichedAptssItems.items),
|
||||
enrichItemIcons(enrichedApmItems.items),
|
||||
installedSources,
|
||||
);
|
||||
console.log(
|
||||
`[UpdateCenter] mergedItems count=${mergedItems.length}`,
|
||||
mergedItems.map(
|
||||
(i) => `${i.pkgname} (${i.source}) ${i.currentVersion}->${i.nextVersion}`,
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
items: mergeUpdateSources(
|
||||
enrichItemIcons(categorizedAptssItems),
|
||||
enrichItemIcons(enrichedApmItems.items),
|
||||
installedSources,
|
||||
),
|
||||
warnings: [...warnings, ...enrichedApmItems.warnings],
|
||||
items: mergedItems,
|
||||
warnings: [
|
||||
...warnings,
|
||||
...enrichedAptssItems.warnings,
|
||||
...enrichedApmItems.warnings,
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -328,8 +524,14 @@ export const registerUpdateCenterIpc = (
|
||||
| "subscribe"
|
||||
>,
|
||||
): void => {
|
||||
ipc.handle("update-center-open", () => service.open());
|
||||
ipc.handle("update-center-refresh", () => service.refresh());
|
||||
ipc.handle(
|
||||
"update-center-open",
|
||||
(_event, storeFilter: StoreFilter = "both") => service.open(storeFilter),
|
||||
);
|
||||
ipc.handle(
|
||||
"update-center-refresh",
|
||||
(_event, storeFilter: StoreFilter = "both") => service.refresh(storeFilter),
|
||||
);
|
||||
ipc.handle(
|
||||
"update-center-ignore",
|
||||
(_event, payload: UpdateCenterIgnorePayload) => service.ignore(payload),
|
||||
@@ -338,8 +540,8 @@ export const registerUpdateCenterIpc = (
|
||||
"update-center-unignore",
|
||||
(_event, payload: UpdateCenterIgnorePayload) => service.unignore(payload),
|
||||
);
|
||||
ipc.handle("update-center-start", (_event, taskKeys: string[]) =>
|
||||
service.start(taskKeys),
|
||||
ipc.handle("update-center-start", (_event, tasks: UpdateCenterStartTask[]) =>
|
||||
service.start(tasks),
|
||||
);
|
||||
ipc.handle("update-center-cancel", (_event, taskKey: string) =>
|
||||
service.cancel(taskKey),
|
||||
@@ -360,14 +562,8 @@ export const initializeUpdateCenter = (): UpdateCenterService => {
|
||||
return updateCenterService;
|
||||
}
|
||||
|
||||
const superUserCmdProvider = async (): Promise<string> => {
|
||||
const installManager = await import("../install-manager.js");
|
||||
return installManager.checkSuperUserCommand();
|
||||
};
|
||||
|
||||
updateCenterService = createUpdateCenterService({
|
||||
loadItems: loadUpdateCenterItems,
|
||||
superUserCmdProvider,
|
||||
});
|
||||
registerUpdateCenterIpc(ipcMain, updateCenterService);
|
||||
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { runAria2Download, type Aria2DownloadResult } from "./download";
|
||||
import { installPackage } from "../shared-installer";
|
||||
import type { UpdateCenterQueue, UpdateCenterTask } from "./queue";
|
||||
import type { UpdateCenterItem } from "./types";
|
||||
|
||||
const SHELL_CALLER_PATH = "/opt/spark-store/extras/shell-caller.sh";
|
||||
const SSINSTALL_PATH = "/usr/bin/ssinstall";
|
||||
const DEFAULT_DOWNLOAD_ROOT = "/tmp/spark-store/update-center";
|
||||
|
||||
export interface UpdateCommand {
|
||||
execCommand: string;
|
||||
execParams: string[];
|
||||
}
|
||||
|
||||
export interface InstallUpdateItemOptions {
|
||||
item: UpdateCenterItem;
|
||||
filePath?: string;
|
||||
@@ -55,94 +48,10 @@ export interface CreateTaskRunnerOptions extends TaskRunnerDependencies {
|
||||
superUserCmd?: string;
|
||||
}
|
||||
|
||||
const runCommand = async (
|
||||
execCommand: string,
|
||||
execParams: string[],
|
||||
onLog?: (message: string) => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(execCommand, execParams, {
|
||||
shell: false,
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
const handleOutput = (chunk: Buffer) => {
|
||||
const message = chunk.toString().trim();
|
||||
if (message) {
|
||||
onLog?.(message);
|
||||
}
|
||||
};
|
||||
|
||||
const abortCommand = () => {
|
||||
child.kill();
|
||||
reject(new Error(`Update task cancelled: ${execParams.join(" ")}`));
|
||||
};
|
||||
|
||||
if (signal?.aborted) {
|
||||
abortCommand();
|
||||
return;
|
||||
}
|
||||
|
||||
signal?.addEventListener("abort", abortCommand, { once: true });
|
||||
|
||||
child.stdout?.on("data", handleOutput);
|
||||
child.stderr?.on("data", handleOutput);
|
||||
child.on("error", reject);
|
||||
child.on("close", (code) => {
|
||||
signal?.removeEventListener("abort", abortCommand);
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error(`${execCommand} exited with code ${code ?? -1}`));
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const buildPrivilegedCommand = (
|
||||
command: string,
|
||||
args: string[],
|
||||
superUserCmd?: string,
|
||||
): UpdateCommand => {
|
||||
if (superUserCmd) {
|
||||
return {
|
||||
execCommand: superUserCmd,
|
||||
execParams: [command, ...args],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
execCommand: command,
|
||||
execParams: args,
|
||||
};
|
||||
};
|
||||
|
||||
export const buildLegacySparkUpgradeCommand = (
|
||||
pkgname: string,
|
||||
superUserCmd = "",
|
||||
): UpdateCommand => {
|
||||
if (superUserCmd) {
|
||||
return {
|
||||
execCommand: superUserCmd,
|
||||
execParams: [
|
||||
SHELL_CALLER_PATH,
|
||||
"aptss",
|
||||
"install",
|
||||
"-y",
|
||||
pkgname,
|
||||
"--only-upgrade",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
execCommand: SHELL_CALLER_PATH,
|
||||
execParams: ["aptss", "install", "-y", pkgname, "--only-upgrade"],
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 安装更新项
|
||||
* 使用与商店安装相同的逻辑
|
||||
*/
|
||||
export const installUpdateItem = async ({
|
||||
item,
|
||||
filePath,
|
||||
@@ -150,45 +59,23 @@ export const installUpdateItem = async ({
|
||||
onLog,
|
||||
signal,
|
||||
}: InstallUpdateItemOptions): Promise<void> => {
|
||||
if (item.source === "apm" && !filePath) {
|
||||
throw new Error("APM update task requires downloaded package metadata");
|
||||
if (!filePath) {
|
||||
throw new Error(
|
||||
`Update task for ${item.pkgname} requires downloaded package file`,
|
||||
);
|
||||
}
|
||||
|
||||
if (item.source === "apm" && filePath) {
|
||||
const installCommand = buildPrivilegedCommand(
|
||||
SHELL_CALLER_PATH,
|
||||
["apm", "ssinstall", filePath],
|
||||
// 使用与商店安装相同的安装逻辑
|
||||
const origin = item.source === "apm" ? "apm" : "spark";
|
||||
|
||||
await installPackage({
|
||||
pkgname: item.pkgname,
|
||||
filePath,
|
||||
origin,
|
||||
superUserCmd,
|
||||
);
|
||||
await runCommand(
|
||||
installCommand.execCommand,
|
||||
installCommand.execParams,
|
||||
onLog,
|
||||
signal,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (filePath) {
|
||||
const installCommand = buildPrivilegedCommand(
|
||||
SSINSTALL_PATH,
|
||||
[filePath, "--delete-after-install"],
|
||||
superUserCmd,
|
||||
);
|
||||
await runCommand(
|
||||
installCommand.execCommand,
|
||||
installCommand.execParams,
|
||||
onLog,
|
||||
signal,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const command = buildLegacySparkUpgradeCommand(
|
||||
item.pkgname,
|
||||
superUserCmd ?? "",
|
||||
);
|
||||
await runCommand(command.execCommand, command.execParams, onLog, signal);
|
||||
});
|
||||
};
|
||||
|
||||
export const createTaskRunner = (
|
||||
@@ -221,11 +108,7 @@ export const createTaskRunner = (
|
||||
|
||||
return {
|
||||
cancelActiveTask: () => {
|
||||
if (!activeAbortController || activeAbortController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeAbortController.abort();
|
||||
activeAbortController?.abort();
|
||||
},
|
||||
runNextTask: async () => {
|
||||
if (inFlightTask) {
|
||||
@@ -246,18 +129,13 @@ export const createTaskRunner = (
|
||||
};
|
||||
|
||||
try {
|
||||
let filePath: string | undefined;
|
||||
|
||||
if (
|
||||
task.item.source === "apm" &&
|
||||
(!task.item.downloadUrl || !task.item.fileName)
|
||||
) {
|
||||
// All updates require download metadata
|
||||
if (!task.item.downloadUrl || !task.item.fileName) {
|
||||
throw new Error(
|
||||
"APM update task requires downloaded package metadata",
|
||||
`Update task for ${task.item.pkgname} requires download metadata (URL and filename)`,
|
||||
);
|
||||
}
|
||||
|
||||
if (task.item.downloadUrl && task.item.fileName) {
|
||||
queue.markActiveTask(task.id, "downloading");
|
||||
const result = await runDownload({
|
||||
item: task.item,
|
||||
@@ -268,8 +146,7 @@ export const createTaskRunner = (
|
||||
queue.updateTaskProgress(task.id, progress);
|
||||
},
|
||||
});
|
||||
filePath = result.filePath;
|
||||
}
|
||||
const filePath = result.filePath;
|
||||
|
||||
queue.markActiveTask(task.id, "installing");
|
||||
await installItem({
|
||||
|
||||
@@ -6,10 +6,9 @@ import type {
|
||||
UpdateSource,
|
||||
} from "./types";
|
||||
|
||||
const UPGRADABLE_PATTERN =
|
||||
/^(\S+)\/\S+\s+([^\s]+)\s+\S+\s+\[(?:upgradable from|from):\s*([^\]]+)\]$/i;
|
||||
const PRINT_URIS_PATTERN = /'([^']+)'\s+(\S+)\s+(\d+)\s+SHA512:([^\s]+)/;
|
||||
const APM_INSTALLED_PATTERN = /^(\S+)\/\S+(?:,\S+)?\s+\S+\s+\S+\s+\[[^\]]+\]$/;
|
||||
const CURRENT_VERSION_PATTERN = /\[(?:upgradable from|from):\s*([^\]\s]+)\]/i;
|
||||
|
||||
const splitVersion = (version: string) => {
|
||||
const epochMatch = version.match(/^(\d+):(.*)$/);
|
||||
@@ -190,18 +189,27 @@ const parseUpgradableOutput = (
|
||||
const items: UpdateCenterItem[] = [];
|
||||
|
||||
for (const line of output.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("Listing")) {
|
||||
const trimmed = line
|
||||
.replace(
|
||||
// eslint-disable-next-line no-control-regex
|
||||
/\x1b\[[0-9;]*m/g,
|
||||
"",
|
||||
)
|
||||
.trim();
|
||||
if (!trimmed || trimmed.startsWith("Listing") || !trimmed.includes("/")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const match = trimmed.match(UPGRADABLE_PATTERN);
|
||||
if (!match) {
|
||||
const tokens = trimmed.split(/\s+/);
|
||||
if (tokens.length < 3) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const [, pkgname, nextVersion, currentVersion] = match;
|
||||
const arch = trimmed.split(/\s+/)[2];
|
||||
const pkgname = tokens[0]?.split("/")[0] ?? "";
|
||||
const nextVersion = tokens[1] ?? "";
|
||||
const arch = tokens[2] ?? "";
|
||||
const currentVersion =
|
||||
trimmed.match(CURRENT_VERSION_PATTERN)?.[1] ?? tokens[5] ?? "";
|
||||
if (!pkgname || nextVersion === currentVersion) {
|
||||
continue;
|
||||
}
|
||||
@@ -254,10 +262,29 @@ const compareVersions = (left: string, right: string): number => {
|
||||
|
||||
export const parseAptssUpgradableOutput = (
|
||||
output: string,
|
||||
): UpdateCenterItem[] => parseUpgradableOutput(output, "aptss");
|
||||
): UpdateCenterItem[] => {
|
||||
console.log(
|
||||
`[UpdateCenter] parseAptssUpgradableOutput input (first 1000 chars): ${output.substring(0, 1000)}`,
|
||||
);
|
||||
const result = parseUpgradableOutput(output, "aptss");
|
||||
console.log(
|
||||
`[UpdateCenter] parseAptssUpgradableOutput result count=${result.length}`,
|
||||
);
|
||||
return result;
|
||||
};
|
||||
|
||||
export const parseApmUpgradableOutput = (output: string): UpdateCenterItem[] =>
|
||||
parseUpgradableOutput(output, "apm");
|
||||
export const parseApmUpgradableOutput = (
|
||||
output: string,
|
||||
): UpdateCenterItem[] => {
|
||||
console.log(
|
||||
`[UpdateCenter] parseApmUpgradableOutput input (first 1000 chars): ${output.substring(0, 1000)}`,
|
||||
);
|
||||
const result = parseUpgradableOutput(output, "apm");
|
||||
console.log(
|
||||
`[UpdateCenter] parseApmUpgradableOutput result count=${result.length}`,
|
||||
);
|
||||
return result;
|
||||
};
|
||||
|
||||
export const parsePrintUrisOutput = (
|
||||
output: string,
|
||||
@@ -265,12 +292,21 @@ export const parsePrintUrisOutput = (
|
||||
UpdateCenterItem,
|
||||
"downloadUrl" | "fileName" | "size" | "sha512"
|
||||
> | null => {
|
||||
const match = output.trim().match(PRINT_URIS_PATTERN);
|
||||
const trimmed = output.trim();
|
||||
console.log(
|
||||
`[UpdateCenter] parsePrintUrisOutput input (first 500 chars): ${trimmed.substring(0, 500)}`,
|
||||
);
|
||||
const match = trimmed.match(PRINT_URIS_PATTERN);
|
||||
if (!match) {
|
||||
console.log(
|
||||
`[UpdateCenter] parsePrintUrisOutput: no match found for pattern ${PRINT_URIS_PATTERN}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const [, downloadUrl, fileName, size, sha512] = match;
|
||||
const [, rawDownloadUrl, fileName, size, sha512] = match;
|
||||
// Clean up the URL: remove backticks and extra spaces
|
||||
const downloadUrl = rawDownloadUrl.replace(/[`'"]/g, "").trim();
|
||||
return {
|
||||
downloadUrl,
|
||||
fileName,
|
||||
|
||||
@@ -88,8 +88,8 @@ export const createUpdateCenterQueue = (): UpdateCenterQueue => {
|
||||
let refreshing = false;
|
||||
let nextTaskId = 1;
|
||||
|
||||
const getTask = (taskId: number): UpdateCenterTask | undefined =>
|
||||
tasks.find((task) => task.id === taskId);
|
||||
const getTaskIndex = (taskId: number): number =>
|
||||
tasks.findIndex((task) => task.id === taskId);
|
||||
|
||||
return {
|
||||
setItems: (nextItems) => {
|
||||
@@ -117,40 +117,59 @@ export const createUpdateCenterQueue = (): UpdateCenterQueue => {
|
||||
return task;
|
||||
},
|
||||
markActiveTask: (taskId, status) => {
|
||||
const task = getTask(taskId);
|
||||
if (!task) {
|
||||
const taskIndex = getTaskIndex(taskId);
|
||||
if (taskIndex === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
task.status = status;
|
||||
// 创建新的 task 对象和新的 tasks 数组以触发状态更新
|
||||
tasks = tasks.map((task, index) =>
|
||||
index === taskIndex ? { ...task, status } : task,
|
||||
);
|
||||
},
|
||||
updateTaskProgress: (taskId, progress) => {
|
||||
const task = getTask(taskId);
|
||||
if (!task) {
|
||||
const taskIndex = getTaskIndex(taskId);
|
||||
if (taskIndex === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
task.progress = clampProgress(progress);
|
||||
// 创建新的 task 对象和新的 tasks 数组以触发状态更新
|
||||
tasks = tasks.map((task, index) =>
|
||||
index === taskIndex
|
||||
? { ...task, progress: clampProgress(progress) }
|
||||
: task,
|
||||
);
|
||||
},
|
||||
appendTaskLog: (taskId, message, time = Date.now()) => {
|
||||
const task = getTask(taskId);
|
||||
if (!task) {
|
||||
const taskIndex = getTaskIndex(taskId);
|
||||
if (taskIndex === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
task.logs = [...task.logs, { time, message }];
|
||||
// 创建新的 task 对象和新的 tasks 数组以触发状态更新
|
||||
tasks = tasks.map((task, index) =>
|
||||
index === taskIndex
|
||||
? { ...task, logs: [...task.logs, { time, message }] }
|
||||
: task,
|
||||
);
|
||||
},
|
||||
finishTask: (taskId, status, error) => {
|
||||
const task = getTask(taskId);
|
||||
if (!task) {
|
||||
const taskIndex = getTaskIndex(taskId);
|
||||
if (taskIndex === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
task.status = status;
|
||||
task.error = error;
|
||||
if (status === "completed") {
|
||||
task.progress = 100;
|
||||
// 创建新的 task 对象和新的 tasks 数组以触发状态更新
|
||||
tasks = tasks.map((task, index) =>
|
||||
index === taskIndex
|
||||
? {
|
||||
...task,
|
||||
status,
|
||||
error,
|
||||
progress: status === "completed" ? 100 : task.progress,
|
||||
}
|
||||
: task,
|
||||
);
|
||||
},
|
||||
getNextQueuedTask: () => tasks.find((task) => task.status === "queued"),
|
||||
getSnapshot: () => createSnapshot(items, tasks, warnings, refreshing),
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import { BrowserWindow } from "electron";
|
||||
import {
|
||||
LEGACY_IGNORE_CONFIG_PATH,
|
||||
IGNORE_CONFIG_PATH,
|
||||
applyIgnoredEntries,
|
||||
createIgnoreKey,
|
||||
loadIgnoredEntries,
|
||||
saveIgnoredEntries,
|
||||
sortIgnoredItems,
|
||||
} from "./ignore-config";
|
||||
import { createTaskRunner, type UpdateCenterTaskRunner } from "./install";
|
||||
import {
|
||||
createUpdateCenterQueue,
|
||||
type UpdateCenterQueue,
|
||||
type UpdateCenterQueueSnapshot,
|
||||
} from "./queue";
|
||||
import type { UpdateCenterItem, UpdateSource } from "./types";
|
||||
|
||||
export type StoreFilter = "spark" | "apm" | "both";
|
||||
|
||||
export interface UpdateCenterLoadedItems {
|
||||
items: UpdateCenterItem[];
|
||||
warnings: string[];
|
||||
@@ -62,12 +64,17 @@ export interface UpdateCenterIgnorePayload {
|
||||
newVersion: string;
|
||||
}
|
||||
|
||||
export interface UpdateCenterStartTask {
|
||||
taskKey: string;
|
||||
id: number;
|
||||
}
|
||||
|
||||
export interface UpdateCenterService {
|
||||
open: () => Promise<UpdateCenterServiceState>;
|
||||
refresh: () => Promise<UpdateCenterServiceState>;
|
||||
open: (storeFilter?: StoreFilter) => Promise<UpdateCenterServiceState>;
|
||||
refresh: (storeFilter?: StoreFilter) => Promise<UpdateCenterServiceState>;
|
||||
ignore: (payload: UpdateCenterIgnorePayload) => Promise<void>;
|
||||
unignore: (payload: UpdateCenterIgnorePayload) => Promise<void>;
|
||||
start: (taskKeys: string[]) => Promise<void>;
|
||||
start: (tasks: UpdateCenterStartTask[]) => Promise<void>;
|
||||
cancel: (taskKey: string) => Promise<void>;
|
||||
getState: () => UpdateCenterServiceState;
|
||||
subscribe: (
|
||||
@@ -76,14 +83,11 @@ export interface UpdateCenterService {
|
||||
}
|
||||
|
||||
export interface CreateUpdateCenterServiceOptions {
|
||||
loadItems: () => Promise<UpdateCenterItem[] | UpdateCenterLoadedItems>;
|
||||
loadItems: (
|
||||
storeFilter: StoreFilter,
|
||||
) => Promise<UpdateCenterItem[] | UpdateCenterLoadedItems>;
|
||||
loadIgnoredEntries?: () => Promise<Set<string>>;
|
||||
saveIgnoredEntries?: (entries: ReadonlySet<string>) => Promise<void>;
|
||||
createTaskRunner?: (
|
||||
queue: UpdateCenterQueue,
|
||||
superUserCmd?: string,
|
||||
) => UpdateCenterTaskRunner;
|
||||
superUserCmdProvider?: () => Promise<string>;
|
||||
}
|
||||
|
||||
const getTaskKey = (
|
||||
@@ -96,7 +100,7 @@ const toState = (
|
||||
items: snapshot.items.map((item) => ({
|
||||
taskKey: getTaskKey(item),
|
||||
packageName: item.pkgname,
|
||||
displayName: item.pkgname,
|
||||
displayName: item.name || item.pkgname,
|
||||
currentVersion: item.currentVersion,
|
||||
newVersion: item.nextVersion,
|
||||
source: item.source,
|
||||
@@ -112,19 +116,9 @@ const toState = (
|
||||
migrationTarget: item.migrationTarget,
|
||||
aptssVersion: item.aptssVersion,
|
||||
})),
|
||||
tasks: snapshot.tasks.map((task) => ({
|
||||
taskKey: getTaskKey(task.item),
|
||||
packageName: task.pkgname,
|
||||
source: task.item.source,
|
||||
localIcon: task.item.localIcon,
|
||||
remoteIcon: task.item.remoteIcon,
|
||||
status: task.status,
|
||||
progress: task.progress,
|
||||
logs: task.logs.map((log) => ({ ...log })),
|
||||
errorMessage: task.error ?? "",
|
||||
})),
|
||||
tasks: [], // 不再展示任务日志
|
||||
warnings: [...snapshot.warnings],
|
||||
hasRunningTasks: snapshot.hasRunningTasks,
|
||||
hasRunningTasks: false, // 任务不在更新中心执行
|
||||
});
|
||||
|
||||
const normalizeLoadedItems = (
|
||||
@@ -145,19 +139,14 @@ export const createUpdateCenterService = (
|
||||
): UpdateCenterService => {
|
||||
const queue = createUpdateCenterQueue();
|
||||
const listeners = new Set<(snapshot: UpdateCenterServiceState) => void>();
|
||||
let currentStoreFilter: StoreFilter = "both";
|
||||
const loadIgnored =
|
||||
options.loadIgnoredEntries ??
|
||||
(() => loadIgnoredEntries(LEGACY_IGNORE_CONFIG_PATH));
|
||||
(() => loadIgnoredEntries(IGNORE_CONFIG_PATH));
|
||||
const saveIgnored =
|
||||
options.saveIgnoredEntries ??
|
||||
((entries: ReadonlySet<string>) =>
|
||||
saveIgnoredEntries(LEGACY_IGNORE_CONFIG_PATH, entries));
|
||||
const createRunner =
|
||||
options.createTaskRunner ??
|
||||
((currentQueue: UpdateCenterQueue, superUserCmd?: string) =>
|
||||
createTaskRunner(currentQueue, { superUserCmd }));
|
||||
let processingPromise: Promise<void> | null = null;
|
||||
let activeRunner: UpdateCenterTaskRunner | null = null;
|
||||
saveIgnoredEntries(IGNORE_CONFIG_PATH, entries));
|
||||
|
||||
const applyWarning = (message: string): void => {
|
||||
queue.finishRefresh([message]);
|
||||
@@ -167,72 +156,50 @@ export const createUpdateCenterService = (
|
||||
|
||||
const emit = (): UpdateCenterServiceState => {
|
||||
const snapshot = getState();
|
||||
for (const listener of listeners) {
|
||||
listeners.forEach((listener) => {
|
||||
listener(snapshot);
|
||||
}
|
||||
});
|
||||
return snapshot;
|
||||
};
|
||||
|
||||
const refresh = async (): Promise<UpdateCenterServiceState> => {
|
||||
const refresh = async (
|
||||
storeFilter: StoreFilter = currentStoreFilter,
|
||||
): Promise<UpdateCenterServiceState> => {
|
||||
currentStoreFilter = storeFilter;
|
||||
console.log(
|
||||
`[UpdateCenter] service.refresh called with storeFilter=${storeFilter}`,
|
||||
);
|
||||
queue.startRefresh();
|
||||
emit();
|
||||
|
||||
try {
|
||||
const ignoredEntries = await loadIgnored();
|
||||
const loadedItems = normalizeLoadedItems(await options.loadItems());
|
||||
const items = applyIgnoredEntries(loadedItems.items, ignoredEntries);
|
||||
console.log(`[UpdateCenter] ignoredEntries count=${ignoredEntries.size}`);
|
||||
const loadedItems = normalizeLoadedItems(
|
||||
await options.loadItems(currentStoreFilter),
|
||||
);
|
||||
console.log(
|
||||
`[UpdateCenter] loadItems returned: items=${loadedItems.items.length}, warnings=${loadedItems.warnings.length}`,
|
||||
loadedItems.warnings,
|
||||
);
|
||||
const items = sortIgnoredItems(
|
||||
applyIgnoredEntries(loadedItems.items, ignoredEntries),
|
||||
);
|
||||
console.log(
|
||||
`[UpdateCenter] after applying ignored: items=${items.length}`,
|
||||
);
|
||||
queue.setItems(items);
|
||||
queue.finishRefresh(loadedItems.warnings);
|
||||
return emit();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[UpdateCenter] refresh error:`, error);
|
||||
queue.setItems([]);
|
||||
applyWarning(message);
|
||||
return emit();
|
||||
}
|
||||
};
|
||||
|
||||
const failQueuedTasks = (message: string): void => {
|
||||
for (const task of queue.getSnapshot().tasks) {
|
||||
if (task.status === "queued") {
|
||||
queue.appendTaskLog(task.id, message);
|
||||
queue.finishTask(task.id, "failed", message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const ensureProcessing = async (): Promise<void> => {
|
||||
if (processingPromise) {
|
||||
return processingPromise;
|
||||
}
|
||||
|
||||
processingPromise = (async () => {
|
||||
let superUserCmd = "";
|
||||
|
||||
try {
|
||||
superUserCmd = (await options.superUserCmdProvider?.()) ?? "";
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
failQueuedTasks(message);
|
||||
applyWarning(message);
|
||||
emit();
|
||||
return;
|
||||
}
|
||||
|
||||
activeRunner = createRunner(queue, superUserCmd);
|
||||
|
||||
while (queue.getNextQueuedTask()) {
|
||||
await activeRunner.runNextTask();
|
||||
emit();
|
||||
}
|
||||
})().finally(() => {
|
||||
processingPromise = null;
|
||||
activeRunner = null;
|
||||
});
|
||||
|
||||
return processingPromise;
|
||||
};
|
||||
|
||||
return {
|
||||
open: refresh,
|
||||
refresh,
|
||||
@@ -248,49 +215,69 @@ export const createUpdateCenterService = (
|
||||
await saveIgnored(entries);
|
||||
await refresh();
|
||||
},
|
||||
async start(taskKeys) {
|
||||
async start(tasks) {
|
||||
const snapshot = queue.getSnapshot();
|
||||
const existingTaskKeys = new Set(
|
||||
snapshot.tasks
|
||||
.filter(
|
||||
(task) =>
|
||||
!["completed", "failed", "cancelled"].includes(task.status),
|
||||
)
|
||||
.map((task) => getTaskKey(task.item)),
|
||||
);
|
||||
const taskIdByKey = new Map(tasks.map((task) => [task.taskKey, task.id]));
|
||||
const selectedItems = snapshot.items.filter(
|
||||
(item) =>
|
||||
taskKeys.includes(getTaskKey(item)) &&
|
||||
!item.ignored &&
|
||||
!existingTaskKeys.has(getTaskKey(item)),
|
||||
(item) => taskIdByKey.has(getTaskKey(item)) && !item.ignored,
|
||||
);
|
||||
|
||||
if (selectedItems.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const item of selectedItems) {
|
||||
queue.enqueueItem(item);
|
||||
}
|
||||
emit();
|
||||
// 获取主窗口的 webContents
|
||||
const mainWindow = BrowserWindow.getAllWindows()[0];
|
||||
const webContents = mainWindow?.webContents;
|
||||
|
||||
await ensureProcessing();
|
||||
},
|
||||
async cancel(taskKey) {
|
||||
const task = queue
|
||||
.getSnapshot()
|
||||
.tasks.find((entry) => getTaskKey(entry.item) === taskKey);
|
||||
|
||||
if (!task) {
|
||||
if (!webContents) {
|
||||
console.error("No main window found");
|
||||
return;
|
||||
}
|
||||
|
||||
queue.finishTask(task.id, "cancelled", "Cancelled");
|
||||
if (["downloading", "installing"].includes(task.status)) {
|
||||
activeRunner?.cancelActiveTask();
|
||||
// 获取当前 items
|
||||
let currentItems = snapshot.items;
|
||||
|
||||
for (const item of selectedItems) {
|
||||
const updateTaskId = taskIdByKey.get(getTaskKey(item));
|
||||
if (!updateTaskId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 构建 metalink URL
|
||||
const metalinkUrl = item.downloadUrl
|
||||
? `${item.downloadUrl}.metalink`
|
||||
: undefined;
|
||||
|
||||
// 发送到主下载队列
|
||||
const installTaskData = {
|
||||
id: updateTaskId,
|
||||
pkgname: item.pkgname,
|
||||
metalinkUrl,
|
||||
filename: item.fileName,
|
||||
upgradeOnly: true,
|
||||
origin: item.source === "apm" ? "apm" : "spark",
|
||||
retry: false,
|
||||
};
|
||||
|
||||
// 通过 IPC 发送到主下载队列
|
||||
webContents.send("queue-install", JSON.stringify(installTaskData));
|
||||
|
||||
// 从更新中心的 items 中移除该应用(不再显示在更新列表中)
|
||||
currentItems = currentItems.filter(
|
||||
(i) => getTaskKey(i) !== getTaskKey(item),
|
||||
);
|
||||
}
|
||||
|
||||
// 更新队列中的 items
|
||||
queue.setItems(currentItems);
|
||||
|
||||
emit();
|
||||
},
|
||||
async cancel(taskKey) {
|
||||
// 取消功能不再需要通过更新中心,直接忽略
|
||||
console.log("Cancel not needed for task:", taskKey);
|
||||
},
|
||||
getState,
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface InstalledSourceState {
|
||||
|
||||
export interface UpdateCenterItem {
|
||||
pkgname: string;
|
||||
name?: string;
|
||||
source: UpdateSource;
|
||||
currentVersion: string;
|
||||
nextVersion: string;
|
||||
|
||||
@@ -45,7 +45,7 @@ class ListenersMap {
|
||||
}
|
||||
}
|
||||
|
||||
const protocols = ["spk"];
|
||||
const protocols = ["spk", "apt"];
|
||||
const listeners = new ListenersMap();
|
||||
|
||||
export const deepLink = {
|
||||
@@ -81,6 +81,23 @@ export function handleCommandLine(commandLine: string[]) {
|
||||
try {
|
||||
const url = new URL(target);
|
||||
|
||||
// Handle apt:// protocol: convert to spk://search/pkgname
|
||||
if (url.protocol === "apt:") {
|
||||
// Format: apt://pkgname
|
||||
const pkgname =
|
||||
url.hostname || url.pathname.split("/").filter(Boolean)[0];
|
||||
if (pkgname) {
|
||||
const query: Query = { pkgname };
|
||||
logger.info(`Deep link: apt protocol converted to search: ${pkgname}`);
|
||||
listeners.emit("search", query);
|
||||
} else {
|
||||
logger.warn(
|
||||
`Deep link: invalid apt format, expected //pkgname, got ${target}`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const action = url.hostname; // 'search'
|
||||
logger.info(`Deep link: action found: ${action}`);
|
||||
|
||||
|
||||
+1121
-6
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,7 @@
|
||||
import { ipcRenderer, contextBridge, type IpcRendererEvent } from "electron";
|
||||
|
||||
type StoreFilter = "spark" | "apm" | "both";
|
||||
|
||||
type UpdateCenterSnapshot = {
|
||||
items: Array<{
|
||||
taskKey: string;
|
||||
@@ -40,7 +42,17 @@ type IpcRendererFacade = {
|
||||
invoke: typeof ipcRenderer.invoke;
|
||||
};
|
||||
|
||||
type WindowControlBridge = {
|
||||
minimize: () => void;
|
||||
toggleMaximize: () => void;
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
type UpdateCenterStateListener = (snapshot: UpdateCenterSnapshot) => void;
|
||||
type UpdateCenterStartTask = {
|
||||
taskKey: string;
|
||||
id: number;
|
||||
};
|
||||
|
||||
const updateCenterStateListeners = new Map<
|
||||
UpdateCenterStateListener,
|
||||
@@ -85,11 +97,17 @@ contextBridge.exposeInMainWorld("apm_store", {
|
||||
})(),
|
||||
});
|
||||
|
||||
contextBridge.exposeInMainWorld("windowControls", {
|
||||
minimize: () => ipcRenderer.send("window-control-minimize"),
|
||||
toggleMaximize: () => ipcRenderer.send("window-control-toggle-maximize"),
|
||||
close: () => ipcRenderer.send("window-control-close"),
|
||||
} satisfies WindowControlBridge);
|
||||
|
||||
contextBridge.exposeInMainWorld("updateCenter", {
|
||||
open: (): Promise<UpdateCenterSnapshot> =>
|
||||
ipcRenderer.invoke("update-center-open"),
|
||||
refresh: (): Promise<UpdateCenterSnapshot> =>
|
||||
ipcRenderer.invoke("update-center-refresh"),
|
||||
open: (storeFilter: StoreFilter = "both"): Promise<UpdateCenterSnapshot> =>
|
||||
ipcRenderer.invoke("update-center-open", storeFilter),
|
||||
refresh: (storeFilter: StoreFilter = "both"): Promise<UpdateCenterSnapshot> =>
|
||||
ipcRenderer.invoke("update-center-refresh", storeFilter),
|
||||
ignore: (payload: {
|
||||
packageName: string;
|
||||
newVersion: string;
|
||||
@@ -98,8 +116,8 @@ contextBridge.exposeInMainWorld("updateCenter", {
|
||||
packageName: string;
|
||||
newVersion: string;
|
||||
}): Promise<void> => ipcRenderer.invoke("update-center-unignore", payload),
|
||||
start: (taskKeys: string[]): Promise<void> =>
|
||||
ipcRenderer.invoke("update-center-start", taskKeys),
|
||||
start: (tasks: UpdateCenterStartTask[]): Promise<void> =>
|
||||
ipcRenderer.invoke("update-center-start", tasks),
|
||||
cancel: (taskKey: string): Promise<void> =>
|
||||
ipcRenderer.invoke("update-center-cancel", taskKey),
|
||||
getState: (): Promise<UpdateCenterSnapshot> =>
|
||||
|
||||
+1
-1
@@ -61,7 +61,7 @@ function launch_app() {
|
||||
# 提取并净化Exec命令
|
||||
exec_command=$(grep -m1 '^Exec=' "$DESKTOP_FILE_PATH" | cut -d= -f2- | sed 's/%.//g')
|
||||
[ -z "$exec_command" ] && return 1
|
||||
[ ! -z "$IS_ACE_ENV" ] && HOST_PREFIX="host-spawn"
|
||||
[ ! -z "$IS_ACE_ENV" ] && HOST_PREFIX="systemd-run --user"
|
||||
exec_command="${HOST_PREFIX} $exec_command"
|
||||
log.info "Launching: $exec_command"
|
||||
${SHELL:-bash} -c " $exec_command" &
|
||||
|
||||
@@ -1,43 +1,6 @@
|
||||
#!/bin/bash
|
||||
readonly ACE_ENVIRONMENTS=(
|
||||
"bookworm-run:amber-ce-bookworm"
|
||||
"trixie-run:amber-ce-trixie"
|
||||
"deepin23-run:amber-ce-deepin23"
|
||||
"sid-run:amber-ce-sid"
|
||||
)
|
||||
dpkg -s "$1" 2>/dev/null | grep -q 'Status: install ok installed' > /dev/null 2>&1
|
||||
RET="$?"
|
||||
if [[ "$RET" != "0" ]] && [[ "$IS_ACE_ENV" == "" ]];then ## 如果未在ACE环境中
|
||||
# 检查包是否已安装
|
||||
# 返回 0 表示已安装,非 0 表示未安装
|
||||
|
||||
for ace_entry in "${ACE_ENVIRONMENTS[@]}"; do
|
||||
ace_cmd=${ace_entry%%:*}
|
||||
if command -v "$ace_cmd" >/dev/null 2>&1; then
|
||||
echo "----------------------------------------"
|
||||
echo "正在检查 $ace_cmd 环境的安装..."
|
||||
echo "----------------------------------------"
|
||||
|
||||
# 在ACE环境中使用dpkg -s检查安装状态
|
||||
# 使用dpkg -s并检查输出中是否包含"Status: install ok installed"
|
||||
$ace_cmd dpkg -s "$1" 2>/dev/null | grep -q 'Status: install ok installed'
|
||||
try_run_ret="$?"
|
||||
|
||||
# 最终检测结果处理
|
||||
if [ "$try_run_ret" -eq 0 ]; then
|
||||
echo "----------------------------------------"
|
||||
echo "在 $ace_cmd 环境中找到了安装"
|
||||
echo "----------------------------------------"
|
||||
exit $try_run_ret
|
||||
else
|
||||
echo "----------------------------------------"
|
||||
echo "在 $ace_cmd 环境中未能找到安装,继续查找"
|
||||
echo "----------------------------------------"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
echo "----------------------------------------"
|
||||
echo "所有已安装的 ACE 环境中未能找到安装,退出"
|
||||
echo "----------------------------------------"
|
||||
exit "$RET"
|
||||
fi
|
||||
## 如果在ACE环境中或者未出错
|
||||
exit "$RET"
|
||||
dpkg -s "$1" 2>/dev/null | grep -q 'Status: install ok installed'
|
||||
exit $?
|
||||
|
||||
@@ -23,6 +23,12 @@ if ! command -v apt >/dev/null 2>&1; then
|
||||
ARGS="$ARGS --no-spark"
|
||||
fi
|
||||
|
||||
# 检查是否是AOSC OS
|
||||
if grep -q "ID=aosc" /etc/os-release; then
|
||||
echo "检测到 AOSC OS"
|
||||
ARGS="$ARGS --no-spark"
|
||||
fi
|
||||
|
||||
# 注意:已移除原先针对 arm64 + wayland 添加 --disable-gpu 的逻辑,
|
||||
# 现在 arm64 设备无论是否使用 wayland 均不再添加此参数。
|
||||
|
||||
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
{
|
||||
lib,
|
||||
buildNpmPackage,
|
||||
importNpmLock,
|
||||
electron,
|
||||
makeWrapper,
|
||||
aria2,
|
||||
apm,
|
||||
coreutils,
|
||||
gnugrep,
|
||||
which,
|
||||
xdg-utils,
|
||||
bash,
|
||||
}:
|
||||
|
||||
buildNpmPackage rec {
|
||||
pname = "spark-store";
|
||||
version = "5.1.1";
|
||||
|
||||
src = lib.cleanSourceWith {
|
||||
src = ../.;
|
||||
filter =
|
||||
path: type:
|
||||
let
|
||||
baseName = baseNameOf path;
|
||||
in
|
||||
!(lib.elem baseName [
|
||||
".git"
|
||||
"dist"
|
||||
"dist-electron"
|
||||
"node_modules"
|
||||
"release"
|
||||
"result"
|
||||
]);
|
||||
};
|
||||
|
||||
npmDeps = importNpmLock {
|
||||
npmRoot = ../.;
|
||||
};
|
||||
npmConfigHook = importNpmLock.npmConfigHook;
|
||||
|
||||
nativeBuildInputs = [
|
||||
makeWrapper
|
||||
];
|
||||
|
||||
env = {
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD = "1";
|
||||
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "1";
|
||||
};
|
||||
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
|
||||
npm run build:vite
|
||||
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
appDir="$out/share/spark-store"
|
||||
|
||||
npm prune --omit=dev --ignore-scripts
|
||||
|
||||
substituteInPlace extras/shell-caller.sh \
|
||||
--replace-fail "/usr/bin/apm" "${lib.getExe' apm "apm"}"
|
||||
|
||||
mkdir -p "$appDir" "$out/bin"
|
||||
cp -r dist dist-electron package.json node_modules extras icons "$appDir"/
|
||||
|
||||
chmod -R u+w "$appDir"
|
||||
find "$appDir/extras" -type f -exec chmod +x {} \;
|
||||
|
||||
substituteInPlace "$appDir/dist-electron/main/index.js" \
|
||||
--replace-fail "/opt/spark-store/extras/shell-caller.sh" "$appDir/extras/shell-caller.sh" \
|
||||
--replace-fail "/opt/spark-store/extras/app-launcher" "$appDir/extras/app-launcher"
|
||||
|
||||
install -Dm644 pkg/usr/share/applications/spark-store.desktop \
|
||||
"$out/share/applications/spark-store.desktop"
|
||||
|
||||
install -Dm644 icons/spark-store.svg \
|
||||
"$out/share/icons/hicolor/scalable/apps/spark-store.svg"
|
||||
|
||||
install -Dm644 icons/spark-store.png \
|
||||
"$out/share/icons/hicolor/512x512/apps/spark-store.png"
|
||||
|
||||
install -Dm644 extras/store.spark-app.spark-store.policy \
|
||||
"$out/share/polkit-1/actions/store.spark-app.spark-store.policy"
|
||||
|
||||
substituteInPlace "$out/share/polkit-1/actions/store.spark-app.spark-store.policy" \
|
||||
--replace-fail "/opt/spark-store/extras/shell-caller.sh" "$appDir/extras/shell-caller.sh"
|
||||
|
||||
cat > "$out/bin/spark-store" <<EOF
|
||||
#!${bash}/bin/bash
|
||||
export PATH="${lib.makeBinPath [
|
||||
aria2
|
||||
coreutils
|
||||
gnugrep
|
||||
which
|
||||
xdg-utils
|
||||
]}:\$PATH"
|
||||
|
||||
electron_args=(--no-sandbox)
|
||||
app_args=()
|
||||
|
||||
root_path="\$(${coreutils}/bin/readlink -f /proc/self/root)"
|
||||
if [ "\$root_path" != "/" ]; then
|
||||
app_args+=(--no-apm)
|
||||
fi
|
||||
|
||||
if [ "\''${IS_ACE_ENV:-}" = "1" ]; then
|
||||
app_args+=(--no-apm)
|
||||
fi
|
||||
|
||||
if ! command -v apt >/dev/null 2>&1; then
|
||||
app_args+=(--no-spark)
|
||||
fi
|
||||
|
||||
if [ -r /etc/os-release ] && ${gnugrep}/bin/grep -q "ID=aosc" /etc/os-release; then
|
||||
app_args+=(--no-spark)
|
||||
fi
|
||||
|
||||
exec ${electron}/bin/electron "\''${electron_args[@]}" "$appDir" "\''${app_args[@]}" "\$@"
|
||||
EOF
|
||||
chmod +x "$out/bin/spark-store"
|
||||
|
||||
patchShebangs "$appDir/extras"
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
postFixup = ''
|
||||
appDir="$out/share/spark-store"
|
||||
|
||||
wrapProgram "$appDir/extras/shell-caller.sh" \
|
||||
--prefix PATH : ${
|
||||
lib.makeBinPath [
|
||||
aria2
|
||||
bash
|
||||
coreutils
|
||||
which
|
||||
xdg-utils
|
||||
]
|
||||
}
|
||||
|
||||
substituteInPlace "$appDir/extras/.shell-caller.sh-wrapped" \
|
||||
--replace-fail "#!/bin/bash" "#!${bash}/bin/bash"
|
||||
'';
|
||||
|
||||
meta = {
|
||||
description = "Client for Spark App Store";
|
||||
homepage = "https://spark-app.store";
|
||||
license = lib.licenses.gpl3Only;
|
||||
mainProgram = "spark-store";
|
||||
platforms = lib.platforms.linux;
|
||||
};
|
||||
}
|
||||
Generated
+980
-2465
File diff suppressed because it is too large
Load Diff
+10464
File diff suppressed because it is too large
Load Diff
+11
-6
@@ -1,11 +1,14 @@
|
||||
{
|
||||
"name": "spark-store",
|
||||
"version": "5.0.0beta3",
|
||||
"version": "5.2.0-alpha.1",
|
||||
"main": "dist-electron/main/index.js",
|
||||
"description": "Client for Spark App Store",
|
||||
"author": "elysia-best <elysia-best@simplelinux.cn.eu.org>",
|
||||
"license": "GPL-3.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
},
|
||||
"keywords": [
|
||||
"electron",
|
||||
"rollup",
|
||||
@@ -14,6 +17,7 @@
|
||||
"vue",
|
||||
"spark-app-store"
|
||||
],
|
||||
"homepage": "https://spark-app.store",
|
||||
"debug": {
|
||||
"env": {
|
||||
"VITE_DEV_SERVER_URL": "http://127.0.0.1:3344/"
|
||||
@@ -26,6 +30,7 @@
|
||||
"build:vite": "vue-tsc --noEmit && vite build --mode production",
|
||||
"build:rpm": "vue-tsc --noEmit && vite build --mode production && electron-builder --config electron-builder.yml --linux rpm",
|
||||
"build:deb": "vue-tsc --noEmit && vite build --mode production && electron-builder --config electron-builder.yml --linux deb",
|
||||
"build:deb-loong64": "vue-tsc --noEmit && vite build --mode production && env ELECTRON_MIRROR=https://github.com/darkyzhou/electron-loong64/releases/download/ electron_use_remote_checksums=1 electron-builder --config electron-builder.yml --loong64 --linux deb",
|
||||
"preview": "vite preview --mode debug",
|
||||
"lint": "eslint --ext .ts,.vue src electron",
|
||||
"lint:fix": "eslint --ext .ts,.vue src electron --fix",
|
||||
@@ -43,16 +48,16 @@
|
||||
"@dotenvx/dotenvx": "^1.51.4",
|
||||
"@eslint/create-config": "^1.11.0",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@loongdotjs/electron-builder": "^26.0.12-1",
|
||||
"@loongdotjs/electron-builder": "^26.8.2-loong1",
|
||||
"@playwright/test": "^1.40.0",
|
||||
"@testing-library/jest-dom": "^6.1.5",
|
||||
"@testing-library/vue": "^8.0.1",
|
||||
"@vitejs/plugin-vue": "^6.0.3",
|
||||
"@vitest/coverage-v8": "^1.0.0",
|
||||
"@vitest/coverage-v8": "^4.1.4",
|
||||
"@vue/test-utils": "^2.4.3",
|
||||
"conventional-changelog": "^7.1.1",
|
||||
"conventional-changelog-angular": "^8.1.0",
|
||||
"electron": "^40.0.0",
|
||||
"electron": "^39.2.7",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
@@ -67,9 +72,9 @@
|
||||
"vite": "^6.4.1",
|
||||
"vite-plugin-electron": "^0.29.0",
|
||||
"vite-plugin-electron-renderer": "^0.14.5",
|
||||
"vitest": "^1.0.0",
|
||||
"vitest": "^4.1.4",
|
||||
"vue": "^3.4.21",
|
||||
"vue-tsc": "^3.2.4"
|
||||
"vue-tsc": "^3.3.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
|
||||
@@ -10,4 +10,4 @@ Keywords=appstore;
|
||||
Terminal=false
|
||||
StartupNotify=true
|
||||
StartupWMClass=spark-store
|
||||
MimeType=x-scheme-handler/spk
|
||||
MimeType=x-scheme-handler/spk;x-scheme-handler/apt
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
TRANSHELL_CONTENT_RUNNING_IN_NOT_ROOT_USER="Informação: Iniciando em modo sem privilégios root! Se ocorrerem problemas, tente executar o comando com privilégios de root."
|
||||
TRANSHELL_CONTENT_INFO_SOURCES_LIST_D_IS_EMPTY="Informação: A pasta sources.list.d está vazia. Nenhuma sincronização será tentada."
|
||||
TRANSHELL_CONTENT_GETTING_SERVER_CONFIG_AND_MIRROR_LIST="Obtendo configuração do servidor e lista de espelhos..."
|
||||
TRANSHELL_CONTENT_PLEASE_USE_APTSS_INSTEAD_OF_APT="Aviso: Embora a mensagem de erro sugira usar apt (ex.: apt install --fix-broken) para corrigir o problema, ao depurar o erro, utilize aptss no lugar (no exemplo, use aptss install --fix-broken)."
|
||||
@@ -324,7 +324,8 @@ bool AppDelegate::editorEvent(QEvent *event, QAbstractItemModel *model,
|
||||
QRect unignoreButtonRect(option.rect.right() - 80, option.rect.top() + (option.rect.height() - 30) / 2, 70, 30);
|
||||
if (unignoreButtonRect.contains(mouseEvent->pos())) {
|
||||
// 发送取消忽略信号
|
||||
emit unignoreApp(packageName);
|
||||
QString newVersion = index.data(Qt::UserRole + 3).toString();
|
||||
emit unignoreApp(packageName, newVersion);
|
||||
return true;
|
||||
}
|
||||
return true; // 消耗其他事件,不允许其他交互
|
||||
@@ -369,8 +370,8 @@ bool AppDelegate::editorEvent(QEvent *event, QAbstractItemModel *model,
|
||||
// 检查是否点击了忽略按钮
|
||||
QRect ignoreButtonRect(rect.right() - 160, rect.top() + (rect.height() - 30) / 2, 70, 30);
|
||||
if (ignoreButtonRect.contains(mouseEvent->pos())) {
|
||||
QString currentVersion = index.data(Qt::UserRole + 2).toString();
|
||||
emit ignoreApp(packageName, currentVersion);
|
||||
QString newVersion = index.data(Qt::UserRole + 3).toString();
|
||||
emit ignoreApp(packageName, newVersion);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ signals:
|
||||
void updateDisplay(const QString &packageName);
|
||||
void updateFinished(bool success); //传递是否完成更新
|
||||
void ignoreApp(const QString &packageName, const QString &version); // 新增:忽略应用信号
|
||||
void unignoreApp(const QString &packageName); // 新增:取消忽略应用信号
|
||||
void unignoreApp(const QString &packageName, const QString &version); // 新增:取消忽略应用信号
|
||||
|
||||
private slots:
|
||||
void updateSpinner(); // 新增槽函数
|
||||
|
||||
@@ -37,6 +37,7 @@ void DownloadManager::startDownload(const QString &packageName, const QString &u
|
||||
QStringList arguments = {
|
||||
"--enable-rpc=false",
|
||||
"--console-log-level=warn",
|
||||
"--async-dns=false",
|
||||
"--summary-interval=1",
|
||||
"--allow-overwrite=true",
|
||||
"--connect-timeout=30",
|
||||
|
||||
@@ -4,40 +4,19 @@
|
||||
#include <QFile>
|
||||
#include <QTextStream>
|
||||
#include <QDebug>
|
||||
#include <unistd.h> // for geteuid
|
||||
|
||||
IgnoreConfig::IgnoreConfig(QObject *parent)
|
||||
: QObject(parent)
|
||||
{
|
||||
// 设置配置文件路径
|
||||
QString configDir;
|
||||
QByteArray sudoUserHomeEnv = qgetenv("SUDO_USER_HOME");
|
||||
|
||||
// // 检查是否以 root 权限运行
|
||||
// if (geteuid() == 0) {
|
||||
// // 首先检查是否有 SUDO_USER_HOME 环境变量(表示是通过 pkexec 提权的普通用户)
|
||||
// QByteArray sudoUserHomeEnv = qgetenv("SUDO_USER_HOME");
|
||||
// if (!sudoUserHomeEnv.isEmpty()) {
|
||||
// // 通过 pkexec 提权的普通用户,使用原用户的配置目录
|
||||
// QString sudoUserHomePath = QString::fromLocal8Bit(sudoUserHomeEnv);
|
||||
// configDir = sudoUserHomePath + "/.config";
|
||||
// } else {
|
||||
// // 获取实际的 HOME 目录来判断是真正的 root 用户还是其他方式提权的用户
|
||||
// QByteArray homeEnv = qgetenv("HOME");
|
||||
// QString homePath = QString::fromLocal8Bit(homeEnv);
|
||||
if (!sudoUserHomeEnv.isEmpty()) {
|
||||
configDir = QString::fromLocal8Bit(sudoUserHomeEnv) + "/.config";
|
||||
} else {
|
||||
configDir = QStandardPaths::writableLocation(QStandardPaths::ConfigLocation);
|
||||
}
|
||||
|
||||
// if (homePath == "/root") {
|
||||
// // 真正的 root 用户,使用 /root/.config
|
||||
// configDir = "/root/.config";
|
||||
// } else {
|
||||
// // 其他方式提权的用户,使用 HOME 目录下的配置
|
||||
// configDir = homePath + "/.config";
|
||||
// }
|
||||
// }
|
||||
// } else {
|
||||
// // 普通用户,使用标准配置目录
|
||||
// configDir = QStandardPaths::writableLocation(QStandardPaths::ConfigLocation);
|
||||
// }
|
||||
configDir = "/etc/";
|
||||
QDir dir(configDir);
|
||||
if (!dir.exists()) {
|
||||
dir.mkpath(".");
|
||||
@@ -64,17 +43,9 @@ void IgnoreConfig::addIgnoredApp(const QString &packageName, const QString &vers
|
||||
saveConfig();
|
||||
}
|
||||
|
||||
void IgnoreConfig::removeIgnoredApp(const QString &packageName)
|
||||
void IgnoreConfig::removeIgnoredApp(const QString &packageName, const QString &version)
|
||||
{
|
||||
// 移除所有该包名的版本
|
||||
auto it = m_ignoredApps.begin();
|
||||
while (it != m_ignoredApps.end()) {
|
||||
if (it->first == packageName) {
|
||||
it = m_ignoredApps.erase(it);
|
||||
} else {
|
||||
++it;
|
||||
}
|
||||
}
|
||||
m_ignoredApps.remove(qMakePair(packageName, version));
|
||||
saveConfig();
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ public:
|
||||
void addIgnoredApp(const QString &packageName, const QString &version);
|
||||
|
||||
// 移除忽略的应用
|
||||
void removeIgnoredApp(const QString &packageName);
|
||||
void removeIgnoredApp(const QString &packageName, const QString &version);
|
||||
|
||||
// 检查应用是否被忽略
|
||||
bool isAppIgnored(const QString &packageName, const QString &version) const;
|
||||
|
||||
@@ -238,10 +238,10 @@ void MainWindow::checkUpdates()
|
||||
for (const auto &item : updateInfo) {
|
||||
QJsonObject obj = item.toObject();
|
||||
QString packageName = obj["package"].toString();
|
||||
QString currentVersion = obj["current_version"].toString();
|
||||
QString newVersion = obj["new_version"].toString();
|
||||
|
||||
// 检查应用是否被忽略
|
||||
if (m_ignoreConfig->isAppIgnored(packageName, currentVersion)) {
|
||||
if (m_ignoreConfig->isAppIgnored(packageName, newVersion)) {
|
||||
// 标记为忽略状态
|
||||
obj["ignored"] = true;
|
||||
ignoredApps.append(obj);
|
||||
@@ -468,9 +468,9 @@ void MainWindow::onIgnoreApp(const QString &packageName, const QString &version)
|
||||
}
|
||||
|
||||
// 新增:处理取消忽略应用的槽函数
|
||||
void MainWindow::onUnignoreApp(const QString &packageName) {
|
||||
void MainWindow::onUnignoreApp(const QString &packageName, const QString &version) {
|
||||
// 从忽略配置中移除应用
|
||||
m_ignoreConfig->removeIgnoredApp(packageName);
|
||||
m_ignoreConfig->removeIgnoredApp(packageName, version);
|
||||
|
||||
// 更新模型中应用的状态
|
||||
QJsonArray updatedApps;
|
||||
|
||||
@@ -43,6 +43,6 @@ private slots:
|
||||
void handleUpdateFinished(bool success); // 新增:处理更新完成的槽函数
|
||||
void handleSelectionChanged(); // 新增:处理选择变化的槽函数
|
||||
void onIgnoreApp(const QString &packageName, const QString &version); // 新增:处理忽略应用的槽函数
|
||||
void onUnignoreApp(const QString &packageName); // 新增:处理取消忽略应用
|
||||
void onUnignoreApp(const QString &packageName, const QString &version); // 新增:处理取消忽略应用
|
||||
};
|
||||
#endif // MAINWINDOW_H
|
||||
+1578
-104
File diff suppressed because it is too large
Load Diff
@@ -20,11 +20,13 @@ Object.defineProperty(window, "ipcRenderer", {
|
||||
invoke: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
|
||||
// Mock window.apm_store
|
||||
Object.defineProperty(window, "apm_store", {
|
||||
value: {
|
||||
arch: "amd64-store",
|
||||
arch: "amd64",
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,452 @@
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/vue";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import App from "@/App.vue";
|
||||
import { recordDownloadedApp } from "@/modules/backendApi";
|
||||
import { setAuthSession } from "@/global/authState";
|
||||
import { downloads } from "@/global/downloadStatus";
|
||||
import type { DownloadResult } from "@/global/typedefinition";
|
||||
|
||||
const invoke = vi.fn();
|
||||
const send = vi.fn();
|
||||
const ipcHandlers = new Map<string, (...args: unknown[]) => void>();
|
||||
|
||||
const setSecondUserSession = () => {
|
||||
setAuthSession({
|
||||
accessToken: "backend-token-b",
|
||||
tokenType: "bearer",
|
||||
user: {
|
||||
id: 2,
|
||||
flarumUserId: "84",
|
||||
username: "second",
|
||||
displayName: "Second User",
|
||||
avatarUrl: "https://bbs.spark-app.store/avatar-b.png",
|
||||
forumLevel: "用户",
|
||||
forumGroups: ["用户"],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const setInitialUserSession = () => {
|
||||
setAuthSession({
|
||||
accessToken: "backend-token",
|
||||
tokenType: "bearer",
|
||||
user: {
|
||||
id: 1,
|
||||
flarumUserId: "42",
|
||||
username: "momen",
|
||||
displayName: "Momen",
|
||||
avatarUrl: "https://bbs.spark-app.store/avatar.png",
|
||||
forumLevel: "管理员",
|
||||
forumGroups: ["管理员"],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const createControlledPromise = <T>() => {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((promiseResolve) => {
|
||||
resolve = promiseResolve;
|
||||
});
|
||||
return { promise, resolve };
|
||||
};
|
||||
|
||||
vi.mock("axios", () => {
|
||||
const get = vi.fn(async (url: string) => {
|
||||
if (url.includes("categories.json")) {
|
||||
return { data: { office: { zh: "办公" } } };
|
||||
}
|
||||
if (url.includes("/office/applist.json")) {
|
||||
return {
|
||||
data: [
|
||||
{
|
||||
Name: "WPS",
|
||||
Pkgname: "wps",
|
||||
Version: "1.0.0",
|
||||
Filename: "wps_1.0.0_amd64.deb",
|
||||
Torrent_address: "",
|
||||
Author: "",
|
||||
Contributor: "",
|
||||
Website: "",
|
||||
Update: "",
|
||||
Size: "",
|
||||
More: "Office suite",
|
||||
Tags: "",
|
||||
img_urls: "[]",
|
||||
icons: "",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return { data: [] };
|
||||
});
|
||||
const post = vi.fn(async () => ({ data: { ok: true } }));
|
||||
|
||||
return {
|
||||
default: {
|
||||
create: () => ({ get, post }),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/modules/updateCenter", () => ({
|
||||
createUpdateCenterStore: () => ({
|
||||
isOpen: { value: false },
|
||||
showCloseConfirm: { value: false },
|
||||
showMigrationConfirm: { value: false },
|
||||
searchQuery: { value: "" },
|
||||
selectedTaskKeys: { value: new Set<string>() },
|
||||
snapshot: {
|
||||
value: { items: [], tasks: [], warnings: [], hasRunningTasks: false },
|
||||
},
|
||||
filteredItems: { value: [] },
|
||||
allSelected: { value: false },
|
||||
someSelected: { value: false },
|
||||
bind: vi.fn(),
|
||||
unbind: vi.fn(),
|
||||
open: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
ignoreItem: vi.fn(),
|
||||
unignoreItem: vi.fn(),
|
||||
toggleSelection: vi.fn(),
|
||||
toggleSelectAll: vi.fn(),
|
||||
getSelectedItems: vi.fn(() => []),
|
||||
closeNow: vi.fn(),
|
||||
startSelected: vi.fn(),
|
||||
requestClose: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/backendApi", () => ({
|
||||
addFavoriteItem: vi.fn(),
|
||||
bulkDeleteFavoriteItems: vi.fn(),
|
||||
createFavoriteFolder: vi.fn(),
|
||||
exchangeFlarumToken: vi.fn(),
|
||||
listFavoriteFolders: vi.fn(async () => []),
|
||||
listFavoriteItems: vi.fn(async () => []),
|
||||
recordDownloadedApp: vi.fn(async () => undefined),
|
||||
setBackendToken: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("App download records", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
ipcHandlers.clear();
|
||||
downloads.value = [];
|
||||
invoke.mockImplementation(async (channel: string) => {
|
||||
if (channel === "get-store-filter") return "apm";
|
||||
if (channel === "check-spark-available") return false;
|
||||
if (channel === "check-apm-available") return true;
|
||||
if (channel === "get-app-version") return "5.0.0";
|
||||
if (channel === "get-system-info") return { distro: "deepin 25" };
|
||||
if (channel === "list-installed") return { success: true, apps: [] };
|
||||
if (channel === "check-installed") return false;
|
||||
return [];
|
||||
});
|
||||
|
||||
Object.assign(window.ipcRenderer, {
|
||||
invoke,
|
||||
send,
|
||||
on: vi.fn((channel: string, handler: (...args: unknown[]) => void) => {
|
||||
ipcHandlers.set(channel, handler);
|
||||
}),
|
||||
off: vi.fn(),
|
||||
});
|
||||
window.apm_store.arch = "amd64";
|
||||
localStorage.clear();
|
||||
setInitialUserSession();
|
||||
|
||||
vi.stubGlobal(
|
||||
"matchMedia",
|
||||
vi.fn(() => ({
|
||||
matches: false,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
})),
|
||||
);
|
||||
vi.stubGlobal("scrollTo", vi.fn());
|
||||
class MockIntersectionObserver {
|
||||
observe = vi.fn();
|
||||
disconnect = vi.fn();
|
||||
unobserve = vi.fn();
|
||||
}
|
||||
vi.stubGlobal("IntersectionObserver", MockIntersectionObserver);
|
||||
});
|
||||
|
||||
it("records a download only after the queued install completes successfully", async () => {
|
||||
render(App);
|
||||
|
||||
await fireEvent.click(
|
||||
await screen.findByRole("button", { name: "全部应用 1" }),
|
||||
);
|
||||
await fireEvent.click(await screen.findByText("WPS"));
|
||||
await fireEvent.click(await screen.findByRole("button", { name: "安装" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(send).toHaveBeenCalledWith(
|
||||
"queue-install",
|
||||
expect.stringContaining('"pkgname":"wps"'),
|
||||
);
|
||||
});
|
||||
expect(recordDownloadedApp).not.toHaveBeenCalled();
|
||||
|
||||
const queuedPayload = vi
|
||||
.mocked(send)
|
||||
.mock.calls.find(
|
||||
([channel]) => channel === "queue-install",
|
||||
)?.[1] as string;
|
||||
const queuedDownload = JSON.parse(queuedPayload) as { id: number };
|
||||
const completion: DownloadResult = {
|
||||
id: queuedDownload.id,
|
||||
time: Date.now(),
|
||||
message: "installed",
|
||||
success: true,
|
||||
exitCode: 0,
|
||||
status: "completed",
|
||||
origin: "apm",
|
||||
};
|
||||
|
||||
ipcHandlers.get("install-complete")?.({}, completion);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(recordDownloadedApp).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
appKey: "app:office:wps",
|
||||
pkgname: "wps",
|
||||
selectedOrigin: "apm",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a pending download record through a failed install retry", async () => {
|
||||
render(App);
|
||||
|
||||
await fireEvent.click(
|
||||
await screen.findByRole("button", { name: "全部应用 1" }),
|
||||
);
|
||||
await fireEvent.click(await screen.findByText("WPS"));
|
||||
await fireEvent.click(await screen.findByRole("button", { name: "安装" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(send).toHaveBeenCalledWith(
|
||||
"queue-install",
|
||||
expect.stringContaining('"pkgname":"wps"'),
|
||||
);
|
||||
});
|
||||
|
||||
const queuedPayload = vi
|
||||
.mocked(send)
|
||||
.mock.calls.find(
|
||||
([channel]) => channel === "queue-install",
|
||||
)?.[1] as string;
|
||||
const queuedDownload = JSON.parse(queuedPayload) as { id: number };
|
||||
const failedCompletion: DownloadResult = {
|
||||
id: queuedDownload.id,
|
||||
time: Date.now(),
|
||||
message: "failed",
|
||||
success: false,
|
||||
exitCode: 1,
|
||||
status: "failed",
|
||||
origin: "apm",
|
||||
};
|
||||
|
||||
ipcHandlers.get("install-complete")?.({}, failedCompletion);
|
||||
downloads.value[0].status = "failed";
|
||||
|
||||
await waitFor(() => {
|
||||
expect(recordDownloadedApp).not.toHaveBeenCalled();
|
||||
expect(screen.getByTitle("重试")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await fireEvent.click(screen.getByTitle("重试"));
|
||||
|
||||
const successfulCompletion: DownloadResult = {
|
||||
id: queuedDownload.id,
|
||||
time: Date.now(),
|
||||
message: "installed",
|
||||
success: true,
|
||||
exitCode: 0,
|
||||
status: "completed",
|
||||
origin: "apm",
|
||||
};
|
||||
|
||||
ipcHandlers.get("install-complete")?.({}, successfulCompletion);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(recordDownloadedApp).toHaveBeenCalledTimes(1);
|
||||
expect(recordDownloadedApp).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
appKey: "app:office:wps",
|
||||
pkgname: "wps",
|
||||
name: "WPS",
|
||||
category: "office",
|
||||
selectedOrigin: "apm",
|
||||
version: "1.0.0",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not record a queued install under a later logged-in user", async () => {
|
||||
render(App);
|
||||
|
||||
await fireEvent.click(
|
||||
await screen.findByRole("button", { name: "全部应用 1" }),
|
||||
);
|
||||
await fireEvent.click(await screen.findByText("WPS"));
|
||||
await fireEvent.click(await screen.findByRole("button", { name: "安装" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(send).toHaveBeenCalledWith(
|
||||
"queue-install",
|
||||
expect.stringContaining('"pkgname":"wps"'),
|
||||
);
|
||||
});
|
||||
|
||||
const queuedPayload = vi
|
||||
.mocked(send)
|
||||
.mock.calls.find(
|
||||
([channel]) => channel === "queue-install",
|
||||
)?.[1] as string;
|
||||
const queuedDownload = JSON.parse(queuedPayload) as { id: number };
|
||||
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Momen" }));
|
||||
await fireEvent.click(await screen.findByText("退出登录"));
|
||||
|
||||
setSecondUserSession();
|
||||
|
||||
const completion: DownloadResult = {
|
||||
id: queuedDownload.id,
|
||||
time: Date.now(),
|
||||
message: "installed",
|
||||
success: true,
|
||||
exitCode: 0,
|
||||
status: "completed",
|
||||
origin: "apm",
|
||||
};
|
||||
|
||||
ipcHandlers.get("install-complete")?.({}, completion);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(recordDownloadedApp).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not bind a queued install to a user who logged in during the APM availability check", async () => {
|
||||
const apmCheck = createControlledPromise<boolean>();
|
||||
let apmCheckCalls = 0;
|
||||
invoke.mockImplementation(async (channel: string) => {
|
||||
if (channel === "get-store-filter") return "apm";
|
||||
if (channel === "check-spark-available") return false;
|
||||
if (channel === "check-apm-available") {
|
||||
apmCheckCalls += 1;
|
||||
return apmCheckCalls === 1 ? true : apmCheck.promise;
|
||||
}
|
||||
if (channel === "get-app-version") return "5.0.0";
|
||||
if (channel === "get-system-info") return { distro: "deepin 25" };
|
||||
if (channel === "list-installed") return { success: true, apps: [] };
|
||||
if (channel === "check-installed") return false;
|
||||
return [];
|
||||
});
|
||||
render(App);
|
||||
|
||||
await fireEvent.click(
|
||||
await screen.findByRole("button", { name: "全部应用 1" }),
|
||||
);
|
||||
await fireEvent.click(await screen.findByText("WPS"));
|
||||
await fireEvent.click(await screen.findByRole("button", { name: "安装" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apmCheckCalls).toBe(2);
|
||||
});
|
||||
setSecondUserSession();
|
||||
apmCheck.resolve(true);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(send).toHaveBeenCalledWith(
|
||||
"queue-install",
|
||||
expect.stringContaining('"pkgname":"wps"'),
|
||||
);
|
||||
});
|
||||
|
||||
const queuedPayload = vi
|
||||
.mocked(send)
|
||||
.mock.calls.find(
|
||||
([channel]) => channel === "queue-install",
|
||||
)?.[1] as string;
|
||||
const queuedDownload = JSON.parse(queuedPayload) as { id: number };
|
||||
const completion: DownloadResult = {
|
||||
id: queuedDownload.id,
|
||||
time: Date.now(),
|
||||
message: "installed",
|
||||
success: true,
|
||||
exitCode: 0,
|
||||
status: "completed",
|
||||
origin: "apm",
|
||||
};
|
||||
|
||||
ipcHandlers.get("install-complete")?.({}, completion);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(recordDownloadedApp).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("cleans up a successful pending record even when the active user does not match", async () => {
|
||||
render(App);
|
||||
|
||||
await fireEvent.click(
|
||||
await screen.findByRole("button", { name: "全部应用 1" }),
|
||||
);
|
||||
await fireEvent.click(await screen.findByText("WPS"));
|
||||
await fireEvent.click(await screen.findByRole("button", { name: "安装" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(send).toHaveBeenCalledWith(
|
||||
"queue-install",
|
||||
expect.stringContaining('"pkgname":"wps"'),
|
||||
);
|
||||
});
|
||||
|
||||
const queuedPayload = vi
|
||||
.mocked(send)
|
||||
.mock.calls.find(
|
||||
([channel]) => channel === "queue-install",
|
||||
)?.[1] as string;
|
||||
const queuedDownload = JSON.parse(queuedPayload) as { id: number };
|
||||
const completion: DownloadResult = {
|
||||
id: queuedDownload.id,
|
||||
time: Date.now(),
|
||||
message: "installed",
|
||||
success: true,
|
||||
exitCode: 0,
|
||||
status: "completed",
|
||||
origin: "apm",
|
||||
};
|
||||
|
||||
setSecondUserSession();
|
||||
ipcHandlers.get("install-complete")?.({}, completion);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(recordDownloadedApp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
setInitialUserSession();
|
||||
ipcHandlers.get("install-complete")?.({}, completion);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(recordDownloadedApp).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/vue";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import App from "@/App.vue";
|
||||
|
||||
const invoke = vi.fn();
|
||||
const updateCenterOpen = vi.fn();
|
||||
|
||||
vi.mock("axios", () => {
|
||||
const get = vi.fn(async () => ({ data: [] }));
|
||||
|
||||
return {
|
||||
default: {
|
||||
create: () => ({ get }),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe("App update center runtime", () => {
|
||||
beforeEach(() => {
|
||||
invoke.mockReset();
|
||||
updateCenterOpen.mockReset();
|
||||
|
||||
invoke.mockImplementation(async (channel: string) => {
|
||||
if (channel === "get-store-filter") return "both";
|
||||
if (channel === "check-spark-available") return true;
|
||||
if (channel === "check-apm-available") return true;
|
||||
if (channel === "get-app-version") return "5.0.0";
|
||||
return [];
|
||||
});
|
||||
|
||||
Object.assign(window.ipcRenderer, {
|
||||
invoke,
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
send: vi.fn(),
|
||||
});
|
||||
|
||||
Object.assign(window, {
|
||||
updateCenter: {
|
||||
open: updateCenterOpen.mockResolvedValue({
|
||||
items: [],
|
||||
tasks: [],
|
||||
warnings: [],
|
||||
hasRunningTasks: false,
|
||||
}),
|
||||
refresh: vi.fn(),
|
||||
ignore: vi.fn(),
|
||||
unignore: vi.fn(),
|
||||
start: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
getState: vi.fn(),
|
||||
onState: vi.fn(),
|
||||
offState: vi.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
window.apm_store.arch = "amd64";
|
||||
|
||||
vi.stubGlobal(
|
||||
"matchMedia",
|
||||
vi.fn(() => ({
|
||||
matches: false,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
it("opens update center with an empty snapshot without throwing", async () => {
|
||||
render(App);
|
||||
|
||||
await fireEvent.click(await screen.findByText("软件更新"));
|
||||
|
||||
expect(updateCenterOpen).toHaveBeenCalledWith("both");
|
||||
expect(await screen.findByText("暂无可展示的更新任务")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/vue";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import App from "@/App.vue";
|
||||
|
||||
const invoke = vi.fn();
|
||||
const open = vi.fn();
|
||||
|
||||
vi.mock("axios", () => {
|
||||
const get = vi.fn(async (url: string) => {
|
||||
if (url.includes("categories.json")) {
|
||||
return { data: {} };
|
||||
}
|
||||
|
||||
if (url.includes("homelinks.json") || url.includes("homelist.json")) {
|
||||
return { data: [] };
|
||||
}
|
||||
|
||||
if (url.includes("applist.json")) {
|
||||
return { data: [] };
|
||||
}
|
||||
|
||||
return { data: [] };
|
||||
});
|
||||
|
||||
return {
|
||||
default: {
|
||||
create: () => ({ get }),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/modules/updateCenter", () => ({
|
||||
createUpdateCenterStore: () => ({
|
||||
isOpen: { value: false },
|
||||
showCloseConfirm: { value: false },
|
||||
showMigrationConfirm: { value: false },
|
||||
searchQuery: { value: "" },
|
||||
selectedTaskKeys: { value: new Set<string>() },
|
||||
snapshot: {
|
||||
value: { items: [], tasks: [], warnings: [], hasRunningTasks: false },
|
||||
},
|
||||
filteredItems: { value: [] },
|
||||
allSelected: { value: false },
|
||||
someSelected: { value: false },
|
||||
bind: vi.fn(),
|
||||
unbind: vi.fn(),
|
||||
open,
|
||||
refresh: vi.fn(),
|
||||
ignoreItem: vi.fn(),
|
||||
unignoreItem: vi.fn(),
|
||||
toggleSelection: vi.fn(),
|
||||
toggleSelectAll: vi.fn(),
|
||||
getSelectedItems: vi.fn(() => []),
|
||||
closeNow: vi.fn(),
|
||||
startSelected: vi.fn(),
|
||||
requestClose: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("App update center entry", () => {
|
||||
beforeEach(() => {
|
||||
invoke.mockReset();
|
||||
open.mockReset();
|
||||
|
||||
invoke.mockImplementation(async (channel: string) => {
|
||||
if (channel === "get-store-filter") return "both";
|
||||
if (channel === "check-spark-available") return true;
|
||||
if (channel === "check-apm-available") return true;
|
||||
if (channel === "get-app-version") return "5.0.0";
|
||||
return [];
|
||||
});
|
||||
|
||||
Object.assign(window.ipcRenderer, {
|
||||
invoke,
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
send: vi.fn(),
|
||||
});
|
||||
|
||||
window.apm_store.arch = "amd64";
|
||||
|
||||
vi.stubGlobal(
|
||||
"matchMedia",
|
||||
vi.fn(() => ({
|
||||
matches: false,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
it("opens update center when clicking the sidebar action", async () => {
|
||||
render(App);
|
||||
|
||||
await fireEvent.click(await screen.findByText("软件更新"));
|
||||
|
||||
expect(open).toHaveBeenCalledWith("both");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/vue";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import AppDetailModal from "@/components/AppDetailModal.vue";
|
||||
import type { App, ReviewTags } from "@/global/typedefinition";
|
||||
|
||||
vi.mock("axios", () => ({
|
||||
default: {
|
||||
get: vi.fn(async () => ({ status: 200, data: "42" })),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ReviewsPanel.vue", () => ({
|
||||
default: {
|
||||
name: "ReviewsPanel",
|
||||
props: ["appKey", "tags", "loggedIn", "canSubmit"],
|
||||
emits: ["request-login", "show-user"],
|
||||
template:
|
||||
'<button type="button" data-testid="reviews-panel" :data-app-key="appKey" :data-origin="tags.origin" :data-version="tags.version" :data-can-submit="String(canSubmit)" @click="$emit(\'show-user\', { id: 31, userDisplayName: \'Detail User\', userAvatarUrl: \'\', rating: 5, content: \'\', version: tags.version, packageArch: tags.packageArch, clientArch: tags.clientArch, distro: tags.distro, origin: tags.origin, category: tags.category, createdAt: \'\', updatedAt: \'\' })"></button>',
|
||||
},
|
||||
}));
|
||||
|
||||
const app: App = {
|
||||
name: "WPS",
|
||||
pkgname: "wps",
|
||||
version: "1.0.0",
|
||||
filename: "wps_1.0.0_amd64.deb",
|
||||
torrent_address: "",
|
||||
author: "",
|
||||
contributor: "",
|
||||
website: "",
|
||||
update: "",
|
||||
size: "110M",
|
||||
more: "Office suite",
|
||||
tags: "office",
|
||||
img_urls: [],
|
||||
icons: "",
|
||||
category: "office",
|
||||
origin: "apm",
|
||||
currentStatus: "not-installed",
|
||||
};
|
||||
|
||||
const sparkApp: App = {
|
||||
...app,
|
||||
name: "WPS Spark",
|
||||
version: "2.0.0",
|
||||
filename: "wps_2.0.0_amd64.deb",
|
||||
origin: "spark",
|
||||
};
|
||||
|
||||
const apmApp: App = {
|
||||
...app,
|
||||
name: "WPS APM",
|
||||
origin: "apm",
|
||||
};
|
||||
|
||||
const mergedApp: App = {
|
||||
...sparkApp,
|
||||
isMerged: true,
|
||||
sparkApp,
|
||||
apmApp,
|
||||
viewingOrigin: "spark",
|
||||
};
|
||||
|
||||
const sparkTags: ReviewTags = {
|
||||
origin: "spark",
|
||||
category: "office",
|
||||
pkgname: "wps",
|
||||
version: "2.0.0",
|
||||
packageArch: "amd64",
|
||||
clientArch: "amd64",
|
||||
distro: "deepin 25",
|
||||
};
|
||||
|
||||
describe("AppDetailModal", () => {
|
||||
beforeEach(() => {
|
||||
window.apm_store.arch = "amd64";
|
||||
});
|
||||
|
||||
it("renders detail content inside a popup-style modal overlay", () => {
|
||||
const { container } = render(AppDetailModal, {
|
||||
attrs: { "data-app-modal": "detail" },
|
||||
props: {
|
||||
show: true,
|
||||
app,
|
||||
screenshots: [],
|
||||
sparkInstalled: false,
|
||||
apmInstalled: false,
|
||||
loggedIn: false,
|
||||
reviewAppKey: "apm:amd64-apm:office:wps",
|
||||
reviewTags: sparkTags,
|
||||
},
|
||||
});
|
||||
|
||||
const overlay = container.querySelector('[data-app-modal="detail"]');
|
||||
expect(overlay).toBeTruthy();
|
||||
expect(overlay?.className).toContain("fixed");
|
||||
const panel = overlay?.querySelector(".modal-panel");
|
||||
expect(panel).toBeTruthy();
|
||||
expect(panel?.className).toContain("overflow-hidden");
|
||||
expect(panel?.className).toContain("lg:max-h-[85vh]");
|
||||
expect(
|
||||
panel?.querySelector('[data-testid="detail-fixed-sidebar"]'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
panel?.querySelector('[data-testid="detail-scroll-content"]'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
panel?.querySelector('[data-testid="detail-scroll-content"]')?.className,
|
||||
).toContain("lg:overflow-y-auto");
|
||||
});
|
||||
|
||||
it("updates review identity when switching a merged app origin", async () => {
|
||||
const rendered = render(AppDetailModal, {
|
||||
props: {
|
||||
show: true,
|
||||
app: mergedApp,
|
||||
screenshots: [],
|
||||
sparkInstalled: true,
|
||||
apmInstalled: true,
|
||||
loggedIn: true,
|
||||
reviewAppKey: "spark:amd64-store:office:wps",
|
||||
reviewTags: sparkTags,
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("reviews-panel")).toHaveAttribute(
|
||||
"data-app-key",
|
||||
"spark:amd64-store:office:wps",
|
||||
);
|
||||
|
||||
await fireEvent.click(screen.getByRole("button", { name: "APM" }));
|
||||
|
||||
expect(screen.getByTestId("reviews-panel")).toHaveAttribute(
|
||||
"data-app-key",
|
||||
"apm:amd64-apm:office:wps",
|
||||
);
|
||||
expect(screen.getByTestId("reviews-panel")).toHaveAttribute(
|
||||
"data-origin",
|
||||
"apm",
|
||||
);
|
||||
expect(screen.getByTestId("reviews-panel")).toHaveAttribute(
|
||||
"data-version",
|
||||
"1.0.0",
|
||||
);
|
||||
expect(rendered.emitted("select-origin")?.[0]?.[0]).toBe("apm");
|
||||
});
|
||||
|
||||
it("marks reviews read-only when the selected origin is not installed", () => {
|
||||
render(AppDetailModal, {
|
||||
props: {
|
||||
show: true,
|
||||
app,
|
||||
screenshots: [],
|
||||
sparkInstalled: false,
|
||||
apmInstalled: false,
|
||||
loggedIn: true,
|
||||
reviewAppKey: "apm:amd64-apm:office:wps",
|
||||
reviewTags: sparkTags,
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("reviews-panel")).toHaveAttribute(
|
||||
"data-can-submit",
|
||||
"false",
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards review user profile events", async () => {
|
||||
const rendered = render(AppDetailModal, {
|
||||
props: {
|
||||
show: true,
|
||||
app,
|
||||
screenshots: [],
|
||||
sparkInstalled: true,
|
||||
apmInstalled: true,
|
||||
loggedIn: true,
|
||||
reviewAppKey: "apm:amd64-apm:office:wps",
|
||||
reviewTags: sparkTags,
|
||||
},
|
||||
});
|
||||
|
||||
await fireEvent.click(screen.getByTestId("reviews-panel"));
|
||||
|
||||
expect(rendered.emitted("show-user")?.[0]?.[0]).toEqual(
|
||||
expect.objectContaining({ userDisplayName: "Detail User" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders favorited state with folder name and still emits favorite", async () => {
|
||||
const rendered = render(AppDetailModal, {
|
||||
props: {
|
||||
show: true,
|
||||
app,
|
||||
screenshots: [],
|
||||
sparkInstalled: false,
|
||||
apmInstalled: false,
|
||||
loggedIn: true,
|
||||
reviewAppKey: "apm:amd64-apm:office:wps",
|
||||
reviewTags: sparkTags,
|
||||
favorited: true,
|
||||
favoriteFolderName: "办公收藏",
|
||||
},
|
||||
});
|
||||
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: "已收藏 · 办公收藏" }),
|
||||
);
|
||||
|
||||
expect(rendered.emitted("favorite")?.[0]?.[0]).toEqual(app);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/vue";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import AppDetailPage from "@/components/AppDetailPage.vue";
|
||||
import type { App, ReviewTags } from "@/global/typedefinition";
|
||||
|
||||
vi.mock("@/components/ReviewsPanel.vue", () => ({
|
||||
default: {
|
||||
name: "ReviewsPanel",
|
||||
props: ["appKey", "tags", "loggedIn", "canSubmit"],
|
||||
emits: ["request-login", "show-user"],
|
||||
template:
|
||||
'<button type="button" data-testid="reviews-panel" :data-app-key="appKey" :data-origin="tags.origin" :data-version="tags.version" :data-can-submit="String(canSubmit)" @click="$emit(\'show-user\', { id: 31, userDisplayName: \'Detail User\', userAvatarUrl: \'\', rating: 5, content: \'\', version: tags.version, packageArch: tags.packageArch, clientArch: tags.clientArch, distro: tags.distro, origin: tags.origin, category: tags.category, createdAt: \'\', updatedAt: \'\' })"></button>',
|
||||
},
|
||||
}));
|
||||
|
||||
const app: App = {
|
||||
name: "WPS",
|
||||
pkgname: "wps",
|
||||
version: "1.0.0",
|
||||
filename: "wps_1.0.0_amd64.deb",
|
||||
torrent_address: "",
|
||||
author: "",
|
||||
contributor: "",
|
||||
website: "",
|
||||
update: "",
|
||||
size: "110M",
|
||||
more: "Office suite",
|
||||
tags: "office",
|
||||
img_urls: [],
|
||||
icons: "",
|
||||
category: "office",
|
||||
origin: "apm",
|
||||
currentStatus: "not-installed",
|
||||
};
|
||||
|
||||
const sparkApp: App = {
|
||||
...app,
|
||||
name: "WPS Spark",
|
||||
version: "2.0.0",
|
||||
filename: "wps_2.0.0_amd64.deb",
|
||||
origin: "spark",
|
||||
};
|
||||
|
||||
const apmApp: App = {
|
||||
...app,
|
||||
name: "WPS APM",
|
||||
version: "1.0.0",
|
||||
filename: "wps_1.0.0_amd64.deb",
|
||||
origin: "apm",
|
||||
};
|
||||
|
||||
const mergedApp: App = {
|
||||
...sparkApp,
|
||||
isMerged: true,
|
||||
sparkApp,
|
||||
apmApp,
|
||||
viewingOrigin: "spark",
|
||||
};
|
||||
|
||||
const sparkTags: ReviewTags = {
|
||||
origin: "spark",
|
||||
category: "office",
|
||||
pkgname: "wps",
|
||||
version: "2.0.0",
|
||||
packageArch: "amd64",
|
||||
clientArch: "amd64",
|
||||
distro: "deepin 25",
|
||||
};
|
||||
|
||||
describe("AppDetailPage", () => {
|
||||
it("renders as page, emits back, and gates favorite for anonymous users", async () => {
|
||||
const rendered = render(AppDetailPage, {
|
||||
props: {
|
||||
app,
|
||||
screenshots: [],
|
||||
sparkInstalled: false,
|
||||
apmInstalled: false,
|
||||
loggedIn: false,
|
||||
reviewAppKey: "apm:amd64-apm:office:wps",
|
||||
reviewTags: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByText("Office suite")).toBeTruthy();
|
||||
await fireEvent.click(screen.getByRole("button", { name: "返回" }));
|
||||
await fireEvent.click(screen.getByRole("button", { name: "收藏" }));
|
||||
|
||||
expect(rendered.emitted("back")).toHaveLength(1);
|
||||
expect(rendered.emitted("request-login")?.[0]?.[0]).toBe(
|
||||
"收藏应用需要登录星火账号。",
|
||||
);
|
||||
});
|
||||
|
||||
it("gates reviews for anonymous users", async () => {
|
||||
const rendered = render(AppDetailPage, {
|
||||
props: {
|
||||
app,
|
||||
screenshots: [],
|
||||
sparkInstalled: false,
|
||||
apmInstalled: false,
|
||||
loggedIn: false,
|
||||
reviewAppKey: "apm:amd64-apm:office:wps",
|
||||
reviewTags: sparkTags,
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId("reviews-panel")).toBeNull();
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: "登录后查看评价" }),
|
||||
);
|
||||
expect(rendered.emitted("request-login")?.[0]?.[0]).toBe(
|
||||
"登录后查看和发表评论。",
|
||||
);
|
||||
});
|
||||
|
||||
it("updates review identity when switching a merged app origin", async () => {
|
||||
render(AppDetailPage, {
|
||||
props: {
|
||||
app: mergedApp,
|
||||
screenshots: [],
|
||||
sparkInstalled: true,
|
||||
apmInstalled: true,
|
||||
loggedIn: true,
|
||||
reviewAppKey: "spark:amd64-store:office:wps",
|
||||
reviewTags: sparkTags,
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("reviews-panel")).toHaveAttribute(
|
||||
"data-app-key",
|
||||
"spark:amd64-store:office:wps",
|
||||
);
|
||||
expect(screen.getByTestId("reviews-panel")).toHaveAttribute(
|
||||
"data-origin",
|
||||
"spark",
|
||||
);
|
||||
|
||||
await fireEvent.click(screen.getByRole("button", { name: "APM" }));
|
||||
|
||||
expect(screen.getByTestId("reviews-panel")).toHaveAttribute(
|
||||
"data-app-key",
|
||||
"apm:amd64-apm:office:wps",
|
||||
);
|
||||
expect(screen.getByTestId("reviews-panel")).toHaveAttribute(
|
||||
"data-origin",
|
||||
"apm",
|
||||
);
|
||||
expect(screen.getByTestId("reviews-panel")).toHaveAttribute(
|
||||
"data-version",
|
||||
"1.0.0",
|
||||
);
|
||||
});
|
||||
|
||||
it("marks reviews read-only when the selected origin is not installed", () => {
|
||||
render(AppDetailPage, {
|
||||
props: {
|
||||
app,
|
||||
screenshots: [],
|
||||
sparkInstalled: false,
|
||||
apmInstalled: false,
|
||||
loggedIn: true,
|
||||
reviewAppKey: "apm:amd64-apm:office:wps",
|
||||
reviewTags: sparkTags,
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("reviews-panel")).toHaveAttribute(
|
||||
"data-can-submit",
|
||||
"false",
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards review user profile events", async () => {
|
||||
const rendered = render(AppDetailPage, {
|
||||
props: {
|
||||
app,
|
||||
screenshots: [],
|
||||
sparkInstalled: true,
|
||||
apmInstalled: true,
|
||||
loggedIn: true,
|
||||
reviewAppKey: "apm:amd64-apm:office:wps",
|
||||
reviewTags: sparkTags,
|
||||
},
|
||||
});
|
||||
|
||||
await fireEvent.click(screen.getByTestId("reviews-panel"));
|
||||
|
||||
expect(rendered.emitted("show-user")?.[0]?.[0]).toEqual(
|
||||
expect.objectContaining({ userDisplayName: "Detail User" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/vue";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import AppListRestoreModal from "@/components/AppListRestoreModal.vue";
|
||||
import type { SyncedAppListItem } from "@/global/typedefinition";
|
||||
|
||||
const createItem = (
|
||||
overrides: Partial<SyncedAppListItem> = {},
|
||||
): SyncedAppListItem => ({
|
||||
pkgname: "spark-notes",
|
||||
origin: "spark",
|
||||
category: "office",
|
||||
version: "1.0.0",
|
||||
packageArch: "amd64",
|
||||
appName: "Spark Notes",
|
||||
iconUrl: "",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("AppListRestoreModal", () => {
|
||||
it("emits selected installable cloud items", async () => {
|
||||
const rendered = render(AppListRestoreModal, {
|
||||
props: {
|
||||
show: true,
|
||||
loading: false,
|
||||
error: "",
|
||||
items: [
|
||||
createItem(),
|
||||
createItem({ pkgname: "amber-ce", appName: "Amber CE" }),
|
||||
],
|
||||
installedKeys: new Set<string>(),
|
||||
},
|
||||
});
|
||||
|
||||
await fireEvent.click(screen.getByLabelText("Spark Notes"));
|
||||
await fireEvent.click(screen.getByRole("button", { name: "加入安装队列" }));
|
||||
|
||||
expect(rendered.emitted("install-selected")?.[0]?.[0]).toEqual([
|
||||
expect.objectContaining({ pkgname: "spark-notes" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("disables already installed cloud items", () => {
|
||||
render(AppListRestoreModal, {
|
||||
props: {
|
||||
show: true,
|
||||
loading: false,
|
||||
error: "",
|
||||
items: [createItem()],
|
||||
installedKeys: new Set(["spark:spark-notes"]),
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByLabelText("Spark Notes")).toBeDisabled();
|
||||
expect(screen.getByText("已安装")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("treats the same package installed from another source as installed", () => {
|
||||
render(AppListRestoreModal, {
|
||||
props: {
|
||||
show: true,
|
||||
loading: false,
|
||||
error: "",
|
||||
items: [createItem({ origin: "spark" })],
|
||||
installedKeys: new Set(["apm:spark-notes"]),
|
||||
installedPackageKeys: new Set(["spark-notes"]),
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByLabelText("Spark Notes")).toBeDisabled();
|
||||
expect(screen.getByText("已安装")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("removes selected items when they become installed", async () => {
|
||||
const rendered = render(AppListRestoreModal, {
|
||||
props: {
|
||||
show: true,
|
||||
loading: false,
|
||||
error: "",
|
||||
items: [createItem()],
|
||||
installedKeys: new Set<string>(),
|
||||
},
|
||||
});
|
||||
|
||||
await fireEvent.click(screen.getByLabelText("Spark Notes"));
|
||||
await rendered.rerender({ installedKeys: new Set(["spark:spark-notes"]) });
|
||||
|
||||
expect(screen.getByLabelText("Spark Notes")).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "加入安装队列" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("removes selected items when the same package becomes installed from another source", async () => {
|
||||
const rendered = render(AppListRestoreModal, {
|
||||
props: {
|
||||
show: true,
|
||||
loading: false,
|
||||
error: "",
|
||||
items: [createItem({ origin: "spark" })],
|
||||
installedKeys: new Set<string>(),
|
||||
installedPackageKeys: new Set<string>(),
|
||||
},
|
||||
});
|
||||
|
||||
await fireEvent.click(screen.getByLabelText("Spark Notes"));
|
||||
await rendered.rerender({
|
||||
installedPackageKeys: new Set(["spark-notes"]),
|
||||
});
|
||||
|
||||
expect(screen.getByLabelText("Spark Notes")).toBeDisabled();
|
||||
expect(screen.getByLabelText("Spark Notes")).not.toBeChecked();
|
||||
expect(screen.getByRole("button", { name: "加入安装队列" })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/vue";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import AppSidebar from "@/components/AppSidebar.vue";
|
||||
import type { SparkUser } from "@/global/typedefinition";
|
||||
|
||||
const baseProps = {
|
||||
activeTab: "all",
|
||||
categoryCounts: { all: 0 },
|
||||
themeMode: "auto" as const,
|
||||
storeFilter: "both" as const,
|
||||
sparkAvailable: true,
|
||||
apmAvailable: true,
|
||||
sidebarEntries: [],
|
||||
entryCounts: {},
|
||||
};
|
||||
|
||||
const user: SparkUser = {
|
||||
id: 1,
|
||||
flarumUserId: "123",
|
||||
username: "momen",
|
||||
displayName: "Momen",
|
||||
avatarUrl: "https://bbs.spark-app.store/avatar.png",
|
||||
forumLevel: "管理员",
|
||||
forumGroups: ["管理员"],
|
||||
};
|
||||
|
||||
describe("AppSidebar account entry", () => {
|
||||
it("prompts login when anonymous", async () => {
|
||||
const rendered = render(AppSidebar, {
|
||||
props: { ...baseProps, currentUser: null },
|
||||
});
|
||||
|
||||
await fireEvent.click(screen.getByRole("button", { name: /登录 \/ 注册/ }));
|
||||
|
||||
expect(rendered.emitted("request-login")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("opens quick menu for logged-in users", async () => {
|
||||
render(AppSidebar, { props: { ...baseProps, currentUser: user } });
|
||||
|
||||
await fireEvent.click(screen.getByRole("button", { name: /Momen/ }));
|
||||
|
||||
expect(screen.getByText("用户管理")).toBeTruthy();
|
||||
expect(screen.getByText("我的收藏")).toBeTruthy();
|
||||
expect(screen.getByText("退出登录")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("closes the quick menu after clicking outside the account area", async () => {
|
||||
render(AppSidebar, { props: { ...baseProps, currentUser: user } });
|
||||
|
||||
await fireEvent.click(screen.getByRole("button", { name: /Momen/ }));
|
||||
expect(screen.getByText("用户管理")).toBeTruthy();
|
||||
|
||||
await fireEvent.mouseDown(document.body);
|
||||
|
||||
expect(screen.queryByRole("button", { name: "用户管理" })).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps long account names inside the sidebar account entry", () => {
|
||||
const longUser: SparkUser = {
|
||||
...user,
|
||||
username: "SuperEndermanSMSuperEndermanSMSuperEndermanSM",
|
||||
displayName: "",
|
||||
};
|
||||
|
||||
const { container } = render(AppSidebar, {
|
||||
props: { ...baseProps, currentUser: longUser },
|
||||
});
|
||||
|
||||
const accountButton = screen.getByRole("button", {
|
||||
name: /SuperEndermanSM/,
|
||||
});
|
||||
const textWrapper = accountButton.querySelector(
|
||||
"[data-testid='account-text']",
|
||||
);
|
||||
const accountName = screen.getByText(longUser.username);
|
||||
|
||||
expect(textWrapper?.className).toContain("min-w-0");
|
||||
expect(accountName.className).toContain("truncate");
|
||||
expect(container.textContent).toContain(longUser.username);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["用户管理", "open-user-management"],
|
||||
["我的收藏", "open-favorites"],
|
||||
["论坛首页", "open-forum"],
|
||||
["修改论坛资料", "edit-profile"],
|
||||
["退出登录", "logout"],
|
||||
] as const)(
|
||||
"closes the quick menu after selecting %s",
|
||||
async (label, eventName) => {
|
||||
const rendered = render(AppSidebar, {
|
||||
props: { ...baseProps, currentUser: user },
|
||||
});
|
||||
|
||||
await fireEvent.click(screen.getByRole("button", { name: /Momen/ }));
|
||||
await fireEvent.click(screen.getByRole("button", { name: label }));
|
||||
|
||||
expect(rendered.emitted(eventName)).toHaveLength(1);
|
||||
expect(screen.queryByRole("button", { name: "用户管理" })).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it("closes the quick menu after selecting a sidebar action", async () => {
|
||||
const rendered = render(AppSidebar, {
|
||||
props: { ...baseProps, currentUser: user },
|
||||
});
|
||||
|
||||
await fireEvent.click(screen.getByRole("button", { name: /Momen/ }));
|
||||
await fireEvent.click(screen.getByRole("button", { name: "应用管理" }));
|
||||
|
||||
expect(rendered.emitted("list")).toHaveLength(1);
|
||||
expect(screen.queryByRole("button", { name: "用户管理" })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { render, screen } from "@testing-library/vue";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import AppSidebar from "@/components/AppSidebar.vue";
|
||||
|
||||
const renderSidebar = (
|
||||
overrides: Partial<InstanceType<typeof AppSidebar>["$props"]> = {},
|
||||
) => {
|
||||
return render(AppSidebar, {
|
||||
props: {
|
||||
activeTab: "all",
|
||||
categoryCounts: { all: 0 },
|
||||
themeMode: "auto",
|
||||
storeFilter: "both",
|
||||
sparkAvailable: true,
|
||||
apmAvailable: true,
|
||||
sidebarEntries: [],
|
||||
entryCounts: {},
|
||||
currentUser: null,
|
||||
...overrides,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
describe("AppSidebar", () => {
|
||||
it("shows management and update entries when at least one source is usable", () => {
|
||||
renderSidebar({ sparkAvailable: true, apmAvailable: false });
|
||||
|
||||
expect(screen.getByText("应用管理")).toBeTruthy();
|
||||
expect(screen.getByText("软件更新")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides management and update entries when both sources are unavailable", () => {
|
||||
renderSidebar({ sparkAvailable: false, apmAvailable: false });
|
||||
|
||||
expect(screen.queryByText("应用管理")).toBeNull();
|
||||
expect(screen.queryByText("软件更新")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const categoryBarSource = readFileSync(
|
||||
resolve(
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
"../../components/CategoryBar.vue",
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
describe("CategoryBar", () => {
|
||||
it("uses the requested blue for the selected category pill", () => {
|
||||
expect(categoryBarSource.toLowerCase()).toContain("background: #2b7fff;");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/vue";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import FavoriteFolderManager from "@/components/FavoriteFolderManager.vue";
|
||||
import type {
|
||||
App,
|
||||
FavoriteFolder,
|
||||
ResolvedFavoriteItem,
|
||||
} from "@/global/typedefinition";
|
||||
|
||||
const folder: FavoriteFolder = {
|
||||
id: 1,
|
||||
name: "默认收藏夹",
|
||||
itemCount: 1,
|
||||
createdAt: "2026-05-18T00:00:00Z",
|
||||
updatedAt: "2026-05-18T00:00:00Z",
|
||||
};
|
||||
|
||||
const item: ResolvedFavoriteItem = {
|
||||
item: {
|
||||
id: 2,
|
||||
appKey: "app:office:wps",
|
||||
pkgname: "wps",
|
||||
name: "WPS",
|
||||
category: "office",
|
||||
iconUrl: "",
|
||||
createdAt: "2026-05-18T00:00:00Z",
|
||||
},
|
||||
status: "downlisted",
|
||||
reason: "已下架",
|
||||
selectedApp: null,
|
||||
};
|
||||
|
||||
const selectedApp: App = {
|
||||
name: "WPS",
|
||||
pkgname: "wps",
|
||||
version: "1.0.0",
|
||||
filename: "wps_1.0.0_amd64.deb",
|
||||
torrent_address: "",
|
||||
author: "",
|
||||
contributor: "",
|
||||
website: "",
|
||||
update: "",
|
||||
size: "110M",
|
||||
more: "Office suite",
|
||||
tags: "office",
|
||||
img_urls: [],
|
||||
icons: "",
|
||||
category: "office",
|
||||
origin: "apm",
|
||||
currentStatus: "not-installed",
|
||||
};
|
||||
|
||||
describe("FavoriteFolderManager", () => {
|
||||
it("shows downlisted favorites and emits bulk delete", async () => {
|
||||
const rendered = render(FavoriteFolderManager, {
|
||||
props: {
|
||||
folders: [folder],
|
||||
activeFolderId: 1,
|
||||
items: [item],
|
||||
loading: false,
|
||||
error: "",
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByText("已下架")).toBeTruthy();
|
||||
await fireEvent.click(screen.getByLabelText("选择 WPS"));
|
||||
await fireEvent.click(screen.getByRole("button", { name: "移除选中" }));
|
||||
|
||||
expect(rendered.emitted("remove-selected")?.[0]?.[0]).toEqual([2]);
|
||||
});
|
||||
|
||||
it("opens a favorite item's app detail from the row content", async () => {
|
||||
const rendered = render(FavoriteFolderManager, {
|
||||
props: {
|
||||
folders: [folder],
|
||||
activeFolderId: 1,
|
||||
items: [{ ...item, status: "installable", selectedApp }],
|
||||
loading: false,
|
||||
error: "",
|
||||
},
|
||||
});
|
||||
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: "打开 WPS 详情" }),
|
||||
);
|
||||
|
||||
expect(rendered.emitted("open-detail")?.[0]?.[0]).toEqual(selectedApp);
|
||||
expect(rendered.emitted("remove-selected")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps checkbox selection isolated from opening app detail", async () => {
|
||||
const rendered = render(FavoriteFolderManager, {
|
||||
props: {
|
||||
folders: [folder],
|
||||
activeFolderId: 1,
|
||||
items: [{ ...item, status: "installable", selectedApp }],
|
||||
loading: false,
|
||||
error: "",
|
||||
},
|
||||
});
|
||||
|
||||
await fireEvent.click(screen.getByLabelText("选择 WPS"));
|
||||
|
||||
expect(rendered.emitted("open-detail")).toBeUndefined();
|
||||
await fireEvent.click(screen.getByRole("button", { name: "移除选中" }));
|
||||
expect(rendered.emitted("remove-selected")?.[0]?.[0]).toEqual([2]);
|
||||
});
|
||||
|
||||
it("selects installable favorites and emits them for installation", async () => {
|
||||
const installableItem: ResolvedFavoriteItem = {
|
||||
...item,
|
||||
status: "installable",
|
||||
reason: "可安装",
|
||||
selectedApp,
|
||||
};
|
||||
const installedItem: ResolvedFavoriteItem = {
|
||||
...item,
|
||||
item: {
|
||||
...item.item,
|
||||
id: 3,
|
||||
pkgname: "installed-app",
|
||||
name: "已安装应用",
|
||||
},
|
||||
status: "installed",
|
||||
reason: "已安装",
|
||||
selectedApp: {
|
||||
...selectedApp,
|
||||
pkgname: "installed-app",
|
||||
name: "已安装应用",
|
||||
},
|
||||
};
|
||||
const rendered = render(FavoriteFolderManager, {
|
||||
props: {
|
||||
folders: [folder],
|
||||
activeFolderId: 1,
|
||||
items: [installableItem, installedItem],
|
||||
loading: false,
|
||||
error: "",
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByRole("button", { name: "加入安装队列" })).toBeDisabled();
|
||||
|
||||
await fireEvent.click(screen.getByRole("button", { name: "选择可安装" }));
|
||||
|
||||
expect(screen.getByLabelText("选择 WPS")).toBeChecked();
|
||||
expect(screen.getByLabelText("选择 已安装应用")).not.toBeChecked();
|
||||
expect(screen.getByText("已选择 1 个可安装应用")).toBeTruthy();
|
||||
await fireEvent.click(screen.getByRole("button", { name: "加入安装队列" }));
|
||||
|
||||
expect(rendered.emitted("install-selected")?.[0]?.[0]).toEqual([
|
||||
installableItem,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/vue";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import FavoriteFolderSelector from "@/components/FavoriteFolderSelector.vue";
|
||||
import type { FavoriteFolder } from "@/global/typedefinition";
|
||||
|
||||
const defaultFolder: FavoriteFolder = {
|
||||
id: 1,
|
||||
name: "默认收藏夹",
|
||||
itemCount: 1,
|
||||
createdAt: "2026-05-18T00:00:00Z",
|
||||
updatedAt: "2026-05-18T00:00:00Z",
|
||||
};
|
||||
|
||||
describe("FavoriteFolderSelector", () => {
|
||||
it("renders above the app detail modal and its child popups", () => {
|
||||
const { container } = render(FavoriteFolderSelector, {
|
||||
props: {
|
||||
show: true,
|
||||
folders: [],
|
||||
},
|
||||
});
|
||||
|
||||
const overlay = container.firstElementChild;
|
||||
|
||||
expect(overlay?.className).toContain("z-[90]");
|
||||
});
|
||||
|
||||
it("does not duplicate the default folder returned by the backend", () => {
|
||||
render(FavoriteFolderSelector, {
|
||||
props: {
|
||||
show: true,
|
||||
folders: [defaultFolder],
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getAllByRole("checkbox", { name: "收藏到 默认收藏夹" }),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("normalizes backend default folder names before adding fallback default", () => {
|
||||
render(FavoriteFolderSelector, {
|
||||
props: {
|
||||
show: true,
|
||||
folders: [{ ...defaultFolder, name: " 默认收藏夹 " }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getAllByRole("checkbox", { name: /收藏到\s*默认收藏夹/ }),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("offers creating a folder while selecting favorites", async () => {
|
||||
const rendered = render(FavoriteFolderSelector, {
|
||||
props: {
|
||||
show: true,
|
||||
folders: [defaultFolder],
|
||||
},
|
||||
});
|
||||
|
||||
await fireEvent.click(screen.getByRole("button", { name: "新建收藏夹" }));
|
||||
|
||||
expect(rendered.emitted("create-folder")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("emits the current draft selection when creating a folder", async () => {
|
||||
const rendered = render(FavoriteFolderSelector, {
|
||||
props: {
|
||||
show: true,
|
||||
folders: [defaultFolder],
|
||||
},
|
||||
});
|
||||
|
||||
await fireEvent.click(screen.getByLabelText("收藏到 默认收藏夹"));
|
||||
await fireEvent.click(screen.getByRole("button", { name: "新建收藏夹" }));
|
||||
|
||||
expect(rendered.emitted("create-folder")?.[0]?.[0]).toEqual([1]);
|
||||
});
|
||||
|
||||
it("emits checked folder ids only after confirmation", async () => {
|
||||
const rendered = render(FavoriteFolderSelector, {
|
||||
props: {
|
||||
show: true,
|
||||
folders: [
|
||||
defaultFolder,
|
||||
{ ...defaultFolder, id: 2, name: "办公收藏", itemCount: 0 },
|
||||
],
|
||||
selectedFolderIds: [defaultFolder.id],
|
||||
},
|
||||
});
|
||||
|
||||
await fireEvent.click(screen.getByLabelText("收藏到 默认收藏夹"));
|
||||
await fireEvent.click(screen.getByLabelText("收藏到 办公收藏"));
|
||||
|
||||
expect(rendered.emitted("save-selection")).toBeUndefined();
|
||||
await fireEvent.click(screen.getByRole("button", { name: "保存收藏夹" }));
|
||||
|
||||
expect(rendered.emitted("save-selection")?.[0]?.[0]).toEqual([2]);
|
||||
});
|
||||
|
||||
it("emits the fallback default folder selection after confirmation", async () => {
|
||||
const rendered = render(FavoriteFolderSelector, {
|
||||
props: {
|
||||
show: true,
|
||||
folders: [],
|
||||
},
|
||||
});
|
||||
|
||||
await fireEvent.click(screen.getByLabelText("收藏到 默认收藏夹"));
|
||||
await fireEvent.click(screen.getByRole("button", { name: "保存收藏夹" }));
|
||||
|
||||
expect(rendered.emitted("save-selection")?.[0]?.[0]).toEqual(["default"]);
|
||||
});
|
||||
|
||||
it("preserves unsaved folder checks when the folder list changes", async () => {
|
||||
const rendered = render(FavoriteFolderSelector, {
|
||||
props: {
|
||||
show: true,
|
||||
folders: [defaultFolder],
|
||||
selectedFolderIds: [],
|
||||
},
|
||||
});
|
||||
|
||||
await fireEvent.click(screen.getByLabelText("收藏到 默认收藏夹"));
|
||||
await rendered.rerender({
|
||||
folders: [
|
||||
defaultFolder,
|
||||
{ ...defaultFolder, id: 2, name: "办公收藏", itemCount: 0 },
|
||||
],
|
||||
});
|
||||
await fireEvent.click(screen.getByLabelText("收藏到 办公收藏"));
|
||||
await fireEvent.click(screen.getByRole("button", { name: "保存收藏夹" }));
|
||||
|
||||
expect(rendered.emitted("save-selection")?.[0]?.[0]).toEqual([1, 2]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/vue";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import InstalledAppsModal from "@/components/InstalledAppsModal.vue";
|
||||
import type { App } from "@/global/typedefinition";
|
||||
|
||||
const createApp = (overrides: Partial<App> = {}): App => ({
|
||||
name: "Spark Notes",
|
||||
pkgname: "spark-notes",
|
||||
version: "1.0.0",
|
||||
filename: "spark-notes.deb",
|
||||
torrent_address: "",
|
||||
author: "",
|
||||
contributor: "",
|
||||
website: "",
|
||||
update: "",
|
||||
size: "1 MB",
|
||||
more: "",
|
||||
tags: "",
|
||||
img_urls: [],
|
||||
icons: "",
|
||||
category: "office",
|
||||
origin: "spark",
|
||||
currentStatus: "installed",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("InstalledAppsModal", () => {
|
||||
it("keeps scroll chaining inside the modal list", () => {
|
||||
const { container } = render(InstalledAppsModal, {
|
||||
props: {
|
||||
show: true,
|
||||
apps: [],
|
||||
loading: false,
|
||||
error: "",
|
||||
activeOrigin: "spark",
|
||||
storeFilter: "both",
|
||||
sparkAvailable: true,
|
||||
apmAvailable: true,
|
||||
loggedIn: false,
|
||||
syncing: false,
|
||||
syncMessage: "",
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByText("已安装应用")).toBeTruthy();
|
||||
const scrollContainer = container.querySelector(".overflow-y-auto");
|
||||
|
||||
expect(scrollContainer?.className).toContain("overscroll-contain");
|
||||
});
|
||||
|
||||
it("renders open and detail actions for a store-backed installed app", () => {
|
||||
render(InstalledAppsModal, {
|
||||
props: {
|
||||
show: true,
|
||||
apps: [createApp()],
|
||||
loading: false,
|
||||
error: "",
|
||||
activeOrigin: "spark",
|
||||
storeFilter: "both",
|
||||
sparkAvailable: true,
|
||||
apmAvailable: true,
|
||||
loggedIn: false,
|
||||
syncing: false,
|
||||
syncMessage: "",
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByRole("button", { name: "打开" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "查看详情" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("emits open-app when clicking 打开", async () => {
|
||||
const rendered = render(InstalledAppsModal, {
|
||||
props: {
|
||||
show: true,
|
||||
apps: [createApp()],
|
||||
loading: false,
|
||||
error: "",
|
||||
activeOrigin: "spark",
|
||||
storeFilter: "both",
|
||||
sparkAvailable: true,
|
||||
apmAvailable: true,
|
||||
loggedIn: false,
|
||||
syncing: false,
|
||||
syncMessage: "",
|
||||
},
|
||||
});
|
||||
|
||||
await fireEvent.click(screen.getByRole("button", { name: "打开" }));
|
||||
|
||||
expect(rendered.emitted("open-app")).toHaveLength(1);
|
||||
expect(rendered.emitted("open-app")?.[0]?.[0]).toMatchObject({
|
||||
pkgname: "spark-notes",
|
||||
});
|
||||
});
|
||||
|
||||
it("emits open-detail when clicking 查看详情", async () => {
|
||||
const rendered = render(InstalledAppsModal, {
|
||||
props: {
|
||||
show: true,
|
||||
apps: [createApp()],
|
||||
loading: false,
|
||||
error: "",
|
||||
activeOrigin: "spark",
|
||||
storeFilter: "both",
|
||||
sparkAvailable: true,
|
||||
apmAvailable: true,
|
||||
loggedIn: false,
|
||||
syncing: false,
|
||||
syncMessage: "",
|
||||
},
|
||||
});
|
||||
|
||||
await fireEvent.click(screen.getByRole("button", { name: "查看详情" }));
|
||||
|
||||
expect(rendered.emitted("open-detail")).toHaveLength(1);
|
||||
expect(rendered.emitted("open-detail")?.[0]?.[0]).toMatchObject({
|
||||
pkgname: "spark-notes",
|
||||
});
|
||||
});
|
||||
|
||||
it("shows 查看详情 for metadata-rich unknown-category apps", () => {
|
||||
render(InstalledAppsModal, {
|
||||
props: {
|
||||
show: true,
|
||||
apps: [createApp({ category: "unknown", more: "Has store metadata" })],
|
||||
loading: false,
|
||||
error: "",
|
||||
activeOrigin: "spark",
|
||||
storeFilter: "both",
|
||||
sparkAvailable: true,
|
||||
apmAvailable: true,
|
||||
loggedIn: false,
|
||||
syncing: false,
|
||||
syncMessage: "",
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByRole("button", { name: "查看详情" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides 查看详情 for unknown-category apps", () => {
|
||||
render(InstalledAppsModal, {
|
||||
props: {
|
||||
show: true,
|
||||
apps: [createApp({ category: "unknown" })],
|
||||
loading: false,
|
||||
error: "",
|
||||
activeOrigin: "spark",
|
||||
storeFilter: "both",
|
||||
sparkAvailable: true,
|
||||
apmAvailable: true,
|
||||
loggedIn: false,
|
||||
syncing: false,
|
||||
syncMessage: "",
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.queryByRole("button", { name: "查看详情" })).toBeNull();
|
||||
});
|
||||
|
||||
it("requests login for cloud actions when logged out", async () => {
|
||||
const rendered = render(InstalledAppsModal, {
|
||||
props: {
|
||||
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: "从账号恢复" }));
|
||||
|
||||
expect(rendered.emitted("request-login")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("emits cloud sync and restore events when logged in", async () => {
|
||||
const rendered = render(InstalledAppsModal, {
|
||||
props: {
|
||||
show: true,
|
||||
apps: [],
|
||||
loading: false,
|
||||
error: "",
|
||||
activeOrigin: "spark",
|
||||
storeFilter: "both",
|
||||
sparkAvailable: true,
|
||||
apmAvailable: true,
|
||||
loggedIn: true,
|
||||
syncing: false,
|
||||
syncMessage: "",
|
||||
},
|
||||
});
|
||||
|
||||
await fireEvent.click(screen.getByRole("button", { name: "同步到账号" }));
|
||||
await fireEvent.click(screen.getByRole("button", { name: "从账号恢复" }));
|
||||
|
||||
expect(rendered.emitted("sync-to-account")).toHaveLength(1);
|
||||
expect(rendered.emitted("restore-from-account")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("disables sync button while syncing", () => {
|
||||
render(InstalledAppsModal, {
|
||||
props: {
|
||||
show: true,
|
||||
apps: [],
|
||||
loading: false,
|
||||
error: "",
|
||||
activeOrigin: "spark",
|
||||
storeFilter: "both",
|
||||
sparkAvailable: true,
|
||||
apmAvailable: true,
|
||||
loggedIn: true,
|
||||
syncing: true,
|
||||
syncMessage: "",
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByRole("button", { name: "同步中" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("shows account sync feedback in the installed apps modal", () => {
|
||||
render(InstalledAppsModal, {
|
||||
props: {
|
||||
show: true,
|
||||
apps: [],
|
||||
loading: false,
|
||||
error: "",
|
||||
activeOrigin: "spark",
|
||||
storeFilter: "both",
|
||||
sparkAvailable: true,
|
||||
apmAvailable: true,
|
||||
loggedIn: true,
|
||||
syncing: false,
|
||||
syncMessage: "同步完成",
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByText("同步完成")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/vue";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import LoginModal from "@/components/LoginModal.vue";
|
||||
|
||||
describe("LoginModal", () => {
|
||||
it("does not show the old password forwarding note", () => {
|
||||
render(LoginModal, {
|
||||
props: { show: true, loading: false, error: "" },
|
||||
});
|
||||
|
||||
expect(screen.queryByText(/密码仅直接/)).toBeNull();
|
||||
});
|
||||
|
||||
it("emits login credentials and register request", async () => {
|
||||
const rendered = render(LoginModal, {
|
||||
props: { show: true, loading: false, error: "" },
|
||||
});
|
||||
|
||||
await fireEvent.update(screen.getByLabelText("论坛账号"), " momen ");
|
||||
await fireEvent.update(screen.getByLabelText("论坛密码"), " secret ");
|
||||
await fireEvent.click(screen.getByRole("button", { name: "登录" }));
|
||||
await fireEvent.click(screen.getByRole("button", { name: "注册账号" }));
|
||||
|
||||
expect(rendered.emitted("login")?.[0]?.[0]).toEqual({
|
||||
identification: "momen",
|
||||
password: "secret",
|
||||
});
|
||||
expect(rendered.emitted("register")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/vue";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import ReviewUserProfileModal from "@/components/ReviewUserProfileModal.vue";
|
||||
import type { ReviewUserProfile } from "@/global/typedefinition";
|
||||
|
||||
const profile = (
|
||||
overrides: Partial<ReviewUserProfile> = {},
|
||||
): ReviewUserProfile => ({
|
||||
displayName: "Momen",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("ReviewUserProfileModal", () => {
|
||||
it("does not insert unsafe cover URLs into background image styles", () => {
|
||||
const { container } = render(ReviewUserProfileModal, {
|
||||
props: {
|
||||
show: true,
|
||||
profile: profile({ coverUrl: "javascript:alert(1)" }),
|
||||
},
|
||||
});
|
||||
|
||||
expect(container.innerHTML).not.toContain("javascript:alert(1)");
|
||||
const cover = container.querySelector("[data-testid='review-user-cover']");
|
||||
expect((cover as HTMLElement | null)?.style.backgroundImage).toBe("");
|
||||
});
|
||||
|
||||
it("emits close when Escape is pressed inside the dialog", async () => {
|
||||
const rendered = render(ReviewUserProfileModal, {
|
||||
props: {
|
||||
show: true,
|
||||
profile: profile(),
|
||||
},
|
||||
});
|
||||
|
||||
await fireEvent.keyDown(screen.getByRole("dialog", { name: "用户资料" }), {
|
||||
key: "Escape",
|
||||
});
|
||||
|
||||
expect(rendered.emitted("close")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("emits the forum profile URL when opening a username profile", async () => {
|
||||
const rendered = render(ReviewUserProfileModal, {
|
||||
props: {
|
||||
show: true,
|
||||
profile: profile({ username: "momen" }),
|
||||
},
|
||||
});
|
||||
|
||||
await fireEvent.click(screen.getByRole("button", { name: "查看论坛资料" }));
|
||||
|
||||
expect(rendered.emitted("open-forum-profile")?.[0]?.[0]).toMatch(
|
||||
/\/u\/momen$/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,672 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/vue";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import ReviewsPanel from "@/components/ReviewsPanel.vue";
|
||||
import {
|
||||
createReviewReply,
|
||||
deleteReview,
|
||||
deleteReviewReply,
|
||||
fetchRatingSummary,
|
||||
fetchReviews,
|
||||
likeReview,
|
||||
likeReviewReply,
|
||||
submitReview,
|
||||
} from "@/modules/backendApi";
|
||||
import type {
|
||||
AppReview,
|
||||
AppReviewReply,
|
||||
RatingSummary,
|
||||
ReviewTags,
|
||||
} from "@/global/typedefinition";
|
||||
|
||||
const emptySummary: RatingSummary = {
|
||||
averageRating: 0,
|
||||
reviewCount: 0,
|
||||
starCounts: {},
|
||||
};
|
||||
|
||||
vi.mock("@/modules/backendApi", () => ({
|
||||
createReviewReply: vi.fn(),
|
||||
deleteReview: vi.fn(),
|
||||
deleteReviewReply: vi.fn(),
|
||||
fetchRatingSummary: vi.fn(async () => emptySummary),
|
||||
fetchReviews: vi.fn(async () => []),
|
||||
likeReview: vi.fn(),
|
||||
likeReviewReply: vi.fn(),
|
||||
submitReview: vi.fn(),
|
||||
}));
|
||||
|
||||
const tags: ReviewTags = {
|
||||
origin: "apm",
|
||||
category: "office",
|
||||
pkgname: "wps",
|
||||
version: "1.0.0",
|
||||
packageArch: "amd64",
|
||||
clientArch: "amd64",
|
||||
distro: "deepin 25",
|
||||
};
|
||||
|
||||
const makeReview = (overrides: Partial<AppReview>): AppReview => ({
|
||||
id: 1,
|
||||
rating: 5,
|
||||
content: "默认评价",
|
||||
version: tags.version,
|
||||
packageArch: tags.packageArch,
|
||||
clientArch: tags.clientArch,
|
||||
distro: tags.distro,
|
||||
origin: tags.origin,
|
||||
category: tags.category,
|
||||
createdAt: "2026-05-20T00:00:00Z",
|
||||
updatedAt: "2026-05-20T00:00:00Z",
|
||||
userDisplayName: "星火用户",
|
||||
userAvatarUrl: "",
|
||||
likeCount: 0,
|
||||
likedByCurrentUser: false,
|
||||
canDelete: false,
|
||||
isAuthor: false,
|
||||
isDeleted: false,
|
||||
replies: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeReply = (overrides: Partial<AppReviewReply>): AppReviewReply => ({
|
||||
id: 101,
|
||||
reviewId: 1,
|
||||
parentId: null,
|
||||
content: "默认回复",
|
||||
createdAt: "2026-05-20T00:00:00Z",
|
||||
updatedAt: "2026-05-20T00:00:00Z",
|
||||
userDisplayName: "回复用户",
|
||||
userAvatarUrl: "",
|
||||
likeCount: 0,
|
||||
likedByCurrentUser: false,
|
||||
canDelete: false,
|
||||
isAuthor: false,
|
||||
isDeleted: false,
|
||||
replies: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("ReviewsPanel", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fetchRatingSummary).mockReset();
|
||||
vi.mocked(fetchReviews).mockReset();
|
||||
vi.mocked(submitReview).mockReset();
|
||||
vi.mocked(likeReview).mockReset();
|
||||
vi.mocked(createReviewReply).mockReset();
|
||||
vi.mocked(deleteReview).mockReset();
|
||||
vi.mocked(likeReviewReply).mockReset();
|
||||
vi.mocked(deleteReviewReply).mockReset();
|
||||
vi.mocked(fetchRatingSummary).mockResolvedValue(emptySummary);
|
||||
vi.mocked(fetchReviews).mockResolvedValue([]);
|
||||
vi.mocked(likeReview).mockResolvedValue({
|
||||
likedByCurrentUser: true,
|
||||
likeCount: 1,
|
||||
});
|
||||
vi.mocked(createReviewReply).mockResolvedValue(makeReply({}));
|
||||
vi.mocked(deleteReview).mockResolvedValue(undefined);
|
||||
vi.mocked(likeReviewReply).mockResolvedValue({
|
||||
likedByCurrentUser: true,
|
||||
likeCount: 1,
|
||||
});
|
||||
vi.mocked(deleteReviewReply).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("shows anonymous login prompt and read-only review tags", () => {
|
||||
render(ReviewsPanel, {
|
||||
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: false },
|
||||
});
|
||||
|
||||
expect(screen.getByText("登录后发表评论")).toBeTruthy();
|
||||
expect(screen.getByText("1.0.0")).toBeTruthy();
|
||||
expect(screen.getByText("deepin 25")).toBeTruthy();
|
||||
expect(fetchRatingSummary).not.toHaveBeenCalled();
|
||||
expect(fetchReviews).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hides the submit form when reviews are read-only", () => {
|
||||
render(ReviewsPanel, {
|
||||
props: {
|
||||
appKey: "apm:amd64-apm:office:wps",
|
||||
tags,
|
||||
loggedIn: true,
|
||||
canSubmit: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.queryByRole("button", { name: "发表评论" })).toBeNull();
|
||||
expect(screen.getByText("安装应用后可发表评论。")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("ignores stale review responses after app key changes", async () => {
|
||||
let resolveFirstSummary!: (summary: RatingSummary) => void;
|
||||
let resolveFirstReviews!: (reviews: AppReview[]) => void;
|
||||
let resolveSecondSummary!: (summary: RatingSummary) => void;
|
||||
let resolveSecondReviews!: (reviews: AppReview[]) => void;
|
||||
|
||||
vi.mocked(fetchRatingSummary)
|
||||
.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveFirstSummary = resolve;
|
||||
}),
|
||||
)
|
||||
.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveSecondSummary = resolve;
|
||||
}),
|
||||
);
|
||||
vi.mocked(fetchReviews)
|
||||
.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveFirstReviews = resolve;
|
||||
}),
|
||||
)
|
||||
.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveSecondReviews = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const rendered = render(ReviewsPanel, {
|
||||
props: { appKey: "first", tags, loggedIn: true },
|
||||
});
|
||||
|
||||
await rendered.rerender({ appKey: "second", tags, loggedIn: true });
|
||||
|
||||
resolveSecondSummary({ averageRating: 5, reviewCount: 1, starCounts: {} });
|
||||
resolveSecondReviews([
|
||||
{
|
||||
id: 2,
|
||||
rating: 5,
|
||||
content: "second review",
|
||||
version: tags.version,
|
||||
packageArch: tags.packageArch,
|
||||
clientArch: tags.clientArch,
|
||||
distro: tags.distro,
|
||||
origin: tags.origin,
|
||||
category: tags.category,
|
||||
createdAt: "2026-05-18T00:00:00Z",
|
||||
updatedAt: "2026-05-18T00:00:00Z",
|
||||
userDisplayName: "Second User",
|
||||
userAvatarUrl: "",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(await screen.findByText("second review")).toBeTruthy();
|
||||
expect(screen.getByText("5.0 / 5 (1)")).toBeTruthy();
|
||||
|
||||
resolveFirstSummary({ averageRating: 1, reviewCount: 1, starCounts: {} });
|
||||
resolveFirstReviews([
|
||||
{
|
||||
id: 1,
|
||||
rating: 1,
|
||||
content: "first review",
|
||||
version: tags.version,
|
||||
packageArch: tags.packageArch,
|
||||
clientArch: tags.clientArch,
|
||||
distro: tags.distro,
|
||||
origin: tags.origin,
|
||||
category: tags.category,
|
||||
createdAt: "2026-05-18T00:00:00Z",
|
||||
updatedAt: "2026-05-18T00:00:00Z",
|
||||
userDisplayName: "First User",
|
||||
userAvatarUrl: "",
|
||||
},
|
||||
]);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(screen.getByText("second review")).toBeTruthy();
|
||||
expect(screen.getByText("5.0 / 5 (1)")).toBeTruthy();
|
||||
expect(screen.queryByText("first review")).toBeNull();
|
||||
expect(screen.queryByText("1.0 / 5 (1)")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows a friendly submit error instead of raw network errors", async () => {
|
||||
vi.mocked(submitReview).mockRejectedValueOnce(new Error("Network Error"));
|
||||
render(ReviewsPanel, {
|
||||
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: true },
|
||||
});
|
||||
|
||||
await fireEvent.update(
|
||||
screen.getByPlaceholderText("分享你的使用体验"),
|
||||
"好用",
|
||||
);
|
||||
await fireEvent.click(screen.getByRole("button", { name: "发表评论" }));
|
||||
|
||||
expect(
|
||||
await screen.findByText("无法连接星火账号服务,请稍后重试。"),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows a re-login prompt when loading reviews with a stale token", async () => {
|
||||
vi.mocked(fetchRatingSummary).mockRejectedValueOnce(
|
||||
new Error("Request failed with status code 401"),
|
||||
);
|
||||
|
||||
render(ReviewsPanel, {
|
||||
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: true },
|
||||
});
|
||||
|
||||
expect(
|
||||
await screen.findByText("登录状态已失效,请重新登录星火账号。"),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("submits the rating selected by sliding over stars", async () => {
|
||||
vi.mocked(submitReview).mockResolvedValueOnce(
|
||||
makeReview({ rating: 3, content: "一般" }),
|
||||
);
|
||||
render(ReviewsPanel, {
|
||||
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: true },
|
||||
});
|
||||
|
||||
const slider = screen.getByRole("slider", { name: "评分" });
|
||||
vi.spyOn(slider, "getBoundingClientRect").mockReturnValue({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 500,
|
||||
height: 40,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 500,
|
||||
bottom: 40,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
|
||||
const pointerDown = new MouseEvent("pointerdown", {
|
||||
bubbles: true,
|
||||
clientX: 250,
|
||||
});
|
||||
const pointerMove = new MouseEvent("pointermove", {
|
||||
bubbles: true,
|
||||
clientX: 250,
|
||||
});
|
||||
const pointerUp = new MouseEvent("pointerup", {
|
||||
bubbles: true,
|
||||
clientX: 250,
|
||||
});
|
||||
Object.defineProperty(pointerDown, "pointerId", { value: 1 });
|
||||
Object.defineProperty(pointerMove, "pointerId", { value: 1 });
|
||||
Object.defineProperty(pointerUp, "pointerId", { value: 1 });
|
||||
|
||||
await fireEvent(slider, pointerDown);
|
||||
await fireEvent(slider, pointerMove);
|
||||
await fireEvent(slider, pointerUp);
|
||||
await fireEvent.update(
|
||||
screen.getByPlaceholderText("分享你的使用体验"),
|
||||
"一般",
|
||||
);
|
||||
await fireEvent.click(screen.getByRole("button", { name: "发表评论" }));
|
||||
|
||||
expect(submitReview).toHaveBeenCalledWith(
|
||||
"apm:amd64-apm:office:wps",
|
||||
expect.objectContaining({ rating: 3 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("updates rating preview from hovering over the star hitbox", async () => {
|
||||
render(ReviewsPanel, {
|
||||
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: true },
|
||||
});
|
||||
|
||||
const slider = screen.getByRole("slider", { name: "评分" });
|
||||
vi.spyOn(slider, "getBoundingClientRect").mockReturnValue({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 500,
|
||||
height: 40,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 500,
|
||||
bottom: 40,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
|
||||
const pointerMove = new MouseEvent("pointermove", {
|
||||
bubbles: true,
|
||||
clientX: 250,
|
||||
});
|
||||
Object.defineProperty(pointerMove, "pointerId", { value: 1 });
|
||||
|
||||
await fireEvent(slider, pointerMove);
|
||||
|
||||
expect(slider).toHaveAttribute("aria-valuenow", "3");
|
||||
});
|
||||
|
||||
it("does not include a trailing rating label in the star slider hitbox", () => {
|
||||
render(ReviewsPanel, {
|
||||
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: true },
|
||||
});
|
||||
|
||||
const slider = screen.getByRole("slider", { name: "评分" });
|
||||
|
||||
expect(slider).not.toHaveTextContent("星");
|
||||
expect(slider).not.toHaveClass("border");
|
||||
expect(slider).not.toHaveClass("bg-amber-50");
|
||||
});
|
||||
|
||||
it("supports keyboard changes for the sliding star rating", async () => {
|
||||
render(ReviewsPanel, {
|
||||
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: true },
|
||||
});
|
||||
|
||||
const slider = screen.getByRole("slider", { name: "评分" });
|
||||
await fireEvent.keyDown(slider, { key: "ArrowLeft" });
|
||||
await fireEvent.keyDown(slider, { key: "ArrowLeft" });
|
||||
|
||||
expect(slider).toHaveAttribute("aria-valuenow", "3");
|
||||
});
|
||||
|
||||
it("filters loaded reviews by package architecture and distro", async () => {
|
||||
vi.mocked(fetchReviews).mockResolvedValue([
|
||||
{
|
||||
id: 11,
|
||||
rating: 5,
|
||||
content: "amd64 deepin",
|
||||
version: tags.version,
|
||||
packageArch: "amd64",
|
||||
clientArch: tags.clientArch,
|
||||
distro: "deepin 25",
|
||||
origin: tags.origin,
|
||||
category: tags.category,
|
||||
createdAt: "2026-05-20T00:00:00Z",
|
||||
updatedAt: "2026-05-20T00:00:00Z",
|
||||
userDisplayName: "Deepin User",
|
||||
userAvatarUrl: "",
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
rating: 4,
|
||||
content: "arm64 gxde",
|
||||
version: tags.version,
|
||||
packageArch: "arm64",
|
||||
clientArch: tags.clientArch,
|
||||
distro: "GXDE OS 25",
|
||||
origin: tags.origin,
|
||||
category: tags.category,
|
||||
createdAt: "2026-05-20T00:00:00Z",
|
||||
updatedAt: "2026-05-20T00:00:00Z",
|
||||
userDisplayName: "GXDE User",
|
||||
userAvatarUrl: "",
|
||||
},
|
||||
]);
|
||||
|
||||
render(ReviewsPanel, {
|
||||
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: true },
|
||||
});
|
||||
|
||||
expect(await screen.findByText("amd64 deepin")).toBeTruthy();
|
||||
expect(screen.getByText("arm64 gxde")).toBeTruthy();
|
||||
|
||||
await fireEvent.change(screen.getByLabelText("按架构筛选"), {
|
||||
target: { value: "arm64" },
|
||||
});
|
||||
|
||||
expect(screen.queryByText("amd64 deepin")).toBeNull();
|
||||
expect(screen.getByText("arm64 gxde")).toBeTruthy();
|
||||
|
||||
await fireEvent.change(screen.getByLabelText("按架构筛选"), {
|
||||
target: { value: "" },
|
||||
});
|
||||
await fireEvent.change(screen.getByLabelText("按发行版筛选"), {
|
||||
target: { value: "GXDE OS 25" },
|
||||
});
|
||||
|
||||
expect(screen.queryByText("amd64 deepin")).toBeNull();
|
||||
expect(screen.getByText("arm64 gxde")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("resets stale review filters when the app key changes", async () => {
|
||||
vi.mocked(fetchReviews)
|
||||
.mockResolvedValueOnce([
|
||||
makeReview({ id: 21, content: "first amd64", packageArch: "amd64" }),
|
||||
makeReview({
|
||||
id: 22,
|
||||
content: "first arm64",
|
||||
packageArch: "arm64",
|
||||
distro: "GXDE OS 25",
|
||||
}),
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
makeReview({
|
||||
id: 23,
|
||||
content: "second only amd64",
|
||||
packageArch: "amd64",
|
||||
}),
|
||||
]);
|
||||
|
||||
const rendered = render(ReviewsPanel, {
|
||||
props: { appKey: "first", tags, loggedIn: true },
|
||||
});
|
||||
|
||||
expect(await screen.findByText("first amd64")).toBeTruthy();
|
||||
await fireEvent.change(screen.getByLabelText("按架构筛选"), {
|
||||
target: { value: "arm64" },
|
||||
});
|
||||
expect(screen.queryByText("first amd64")).toBeNull();
|
||||
expect(screen.getByText("first arm64")).toBeTruthy();
|
||||
|
||||
await rendered.rerender({ appKey: "second", tags, loggedIn: true });
|
||||
|
||||
expect(await screen.findByText("second only amd64")).toBeTruthy();
|
||||
expect(screen.queryByText("没有符合筛选条件的评价")).toBeNull();
|
||||
});
|
||||
|
||||
it("exposes reviewer detail affordances from avatar and name buttons", async () => {
|
||||
vi.mocked(fetchReviews).mockResolvedValue([
|
||||
makeReview({
|
||||
id: 31,
|
||||
content: "用户资料入口",
|
||||
userDisplayName: "Detail User",
|
||||
}),
|
||||
]);
|
||||
const { emitted } = render(ReviewsPanel, {
|
||||
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: true },
|
||||
});
|
||||
|
||||
await fireEvent.click(
|
||||
await screen.findByRole("button", { name: "查看Detail User的资料" }),
|
||||
);
|
||||
expect(screen.getByText("正在查看 Detail User 的资料")).toBeTruthy();
|
||||
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Detail User" }));
|
||||
expect(emitted()["show-user"]).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("calls backend review actions and only shows delete from backend metadata", async () => {
|
||||
vi.mocked(fetchReviews).mockResolvedValue([
|
||||
makeReview({ id: 41, content: "普通评价", userDisplayName: "Reader" }),
|
||||
makeReview({
|
||||
id: 42,
|
||||
content: "作者评价",
|
||||
userDisplayName: "Author",
|
||||
isAuthor: true,
|
||||
canDelete: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
const rendered = render(ReviewsPanel, {
|
||||
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: true },
|
||||
});
|
||||
|
||||
expect(await screen.findByText("普通评价")).toBeTruthy();
|
||||
expect(screen.getAllByRole("button", { name: "点赞" })).toHaveLength(2);
|
||||
expect(screen.getAllByRole("button", { name: "回复" })).toHaveLength(2);
|
||||
expect(screen.getAllByRole("button", { name: "删除" })).toHaveLength(1);
|
||||
|
||||
await fireEvent.click(screen.getAllByRole("button", { name: "点赞" })[0]);
|
||||
expect(likeReview).toHaveBeenCalledWith("apm:amd64-apm:office:wps", 41);
|
||||
expect(fetchReviews).toHaveBeenCalledTimes(2);
|
||||
expect(await screen.findByText("作者评价")).toBeTruthy();
|
||||
|
||||
await fireEvent.click(screen.getAllByRole("button", { name: "删除" })[0]);
|
||||
expect(deleteReview).toHaveBeenCalledWith("apm:amd64-apm:office:wps", 42);
|
||||
|
||||
await rendered.rerender({
|
||||
appKey: "apm:amd64-apm:office:wps",
|
||||
tags,
|
||||
loggedIn: true,
|
||||
currentUserIsAdmin: true,
|
||||
});
|
||||
|
||||
expect(screen.getAllByRole("button", { name: "删除" })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("creates review replies and nested replies through backend APIs", async () => {
|
||||
vi.mocked(fetchReviews).mockResolvedValue([
|
||||
makeReview({
|
||||
id: 51,
|
||||
content: "可回复评价",
|
||||
replies: [
|
||||
makeReply({
|
||||
id: 151,
|
||||
reviewId: 51,
|
||||
content: "已有回复",
|
||||
replies: [
|
||||
makeReply({
|
||||
id: 152,
|
||||
reviewId: 51,
|
||||
parentId: 151,
|
||||
content: "已有二级回复",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
render(ReviewsPanel, {
|
||||
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: true },
|
||||
});
|
||||
|
||||
await fireEvent.click(
|
||||
(await screen.findAllByRole("button", { name: "回复" }))[0],
|
||||
);
|
||||
expect(screen.getByText("已有二级回复")).toBeTruthy();
|
||||
await fireEvent.update(
|
||||
screen.getByPlaceholderText("写下你的回复"),
|
||||
"一级回复",
|
||||
);
|
||||
await fireEvent.click(screen.getByRole("button", { name: "发送回复" }));
|
||||
|
||||
expect(createReviewReply).toHaveBeenCalledWith(
|
||||
"apm:amd64-apm:office:wps",
|
||||
51,
|
||||
{ content: "一级回复" },
|
||||
);
|
||||
expect(await screen.findByText("已有回复")).toBeTruthy();
|
||||
|
||||
await fireEvent.click(screen.getAllByRole("button", { name: "回复" })[1]);
|
||||
await fireEvent.update(
|
||||
screen.getByPlaceholderText("写下你的回复"),
|
||||
"二级回复",
|
||||
);
|
||||
await fireEvent.click(screen.getByRole("button", { name: "发送回复" }));
|
||||
|
||||
expect(createReviewReply).toHaveBeenCalledWith(
|
||||
"apm:amd64-apm:office:wps",
|
||||
51,
|
||||
{ content: "二级回复", parentId: 151 },
|
||||
);
|
||||
});
|
||||
|
||||
it("calls backend reply actions and shows permission errors", async () => {
|
||||
vi.mocked(deleteReview).mockRejectedValueOnce(
|
||||
new Error("请登录星火账号后重试。"),
|
||||
);
|
||||
vi.mocked(fetchReviews).mockResolvedValue([
|
||||
makeReview({
|
||||
id: 61,
|
||||
content: "带回复评价",
|
||||
canDelete: true,
|
||||
replies: [
|
||||
makeReply({
|
||||
id: 161,
|
||||
reviewId: 61,
|
||||
content: "可操作回复",
|
||||
canDelete: true,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
render(ReviewsPanel, {
|
||||
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: true },
|
||||
});
|
||||
|
||||
expect(await screen.findByText("可操作回复")).toBeTruthy();
|
||||
|
||||
await fireEvent.click(screen.getAllByRole("button", { name: "点赞" })[1]);
|
||||
expect(likeReviewReply).toHaveBeenCalledWith(
|
||||
"apm:amd64-apm:office:wps",
|
||||
61,
|
||||
161,
|
||||
);
|
||||
expect(await screen.findByText("可操作回复")).toBeTruthy();
|
||||
|
||||
await fireEvent.click(screen.getAllByRole("button", { name: "删除" })[0]);
|
||||
expect(await screen.findByText("请登录星火账号后重试。"));
|
||||
|
||||
await fireEvent.click(screen.getAllByRole("button", { name: "删除" })[1]);
|
||||
expect(deleteReviewReply).toHaveBeenCalledWith(
|
||||
"apm:amd64-apm:office:wps",
|
||||
61,
|
||||
161,
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves stale-token prompts from review action failures", async () => {
|
||||
vi.mocked(likeReview).mockRejectedValueOnce(
|
||||
new Error("登录状态已失效,请重新登录星火账号。"),
|
||||
);
|
||||
vi.mocked(fetchReviews).mockResolvedValue([
|
||||
makeReview({ id: 71, content: "旧登录态评价" }),
|
||||
]);
|
||||
|
||||
render(ReviewsPanel, {
|
||||
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: true },
|
||||
});
|
||||
|
||||
expect(await screen.findByText("旧登录态评价")).toBeTruthy();
|
||||
|
||||
await fireEvent.click(screen.getByRole("button", { name: "点赞" }));
|
||||
|
||||
expect(
|
||||
await screen.findByText("登录状态已失效,请重新登录星火账号。"),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows reviewer avatars when available", async () => {
|
||||
vi.mocked(fetchRatingSummary).mockResolvedValue({
|
||||
averageRating: 5,
|
||||
reviewCount: 1,
|
||||
starCounts: { 5: 1 },
|
||||
});
|
||||
vi.mocked(fetchReviews).mockResolvedValue([
|
||||
{
|
||||
id: 3,
|
||||
rating: 5,
|
||||
content: "头像正常显示",
|
||||
version: tags.version,
|
||||
packageArch: tags.packageArch,
|
||||
clientArch: tags.clientArch,
|
||||
distro: tags.distro,
|
||||
origin: tags.origin,
|
||||
category: tags.category,
|
||||
createdAt: "2026-05-19T00:00:00Z",
|
||||
updatedAt: "2026-05-19T00:00:00Z",
|
||||
userDisplayName: "Avatar User",
|
||||
userAvatarUrl: "https://bbs.spark-app.store/avatar.png",
|
||||
},
|
||||
]);
|
||||
|
||||
render(ReviewsPanel, {
|
||||
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: true },
|
||||
});
|
||||
|
||||
const avatar = await screen.findByAltText("Avatar User 的头像");
|
||||
expect(avatar).toHaveAttribute(
|
||||
"src",
|
||||
"https://bbs.spark-app.store/avatar.png",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/vue";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import UserManagementModal from "@/components/UserManagementModal.vue";
|
||||
import type { SparkUser } from "@/global/typedefinition";
|
||||
|
||||
const user: SparkUser = {
|
||||
id: 1,
|
||||
flarumUserId: "42",
|
||||
username: "momen",
|
||||
displayName: "Momen",
|
||||
avatarUrl: "https://bbs.spark-app.store/avatar.png",
|
||||
coverUrl: "https://bbs.spark-app.store/assets/covers/JizZCVjiSFASrEfp.jpg",
|
||||
forumLevel: "管理员",
|
||||
forumGroups: ["管理员"],
|
||||
};
|
||||
|
||||
describe("UserManagementModal", () => {
|
||||
it("renders account management in an independent iframe without token query params", () => {
|
||||
render(UserManagementModal, {
|
||||
props: {
|
||||
show: true,
|
||||
user,
|
||||
downloadedApps: [],
|
||||
syncEnabled: true,
|
||||
loading: false,
|
||||
error: "",
|
||||
},
|
||||
});
|
||||
|
||||
const frame = screen.getByTitle("星火账号用户管理") as HTMLIFrameElement;
|
||||
expect(frame).toBeTruthy();
|
||||
expect(frame.src).toContain("account.spark-app.store");
|
||||
expect(frame.src).toContain("/account");
|
||||
expect(frame.src).not.toMatch(/token|jwt|password|access/i);
|
||||
});
|
||||
|
||||
it("shows retry controls when iframe reports a load failure", async () => {
|
||||
render(UserManagementModal, {
|
||||
props: {
|
||||
show: true,
|
||||
user,
|
||||
downloadedApps: [],
|
||||
syncEnabled: true,
|
||||
loading: false,
|
||||
error: "",
|
||||
},
|
||||
});
|
||||
|
||||
await fireEvent.error(screen.getByTitle("星火账号用户管理"));
|
||||
|
||||
expect(screen.getByText("账号页面加载失败")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "重试" })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { render, screen } from "@testing-library/vue";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import UserManagementView from "@/components/UserManagementView.vue";
|
||||
import type { DownloadedAppRecord, SparkUser } from "@/global/typedefinition";
|
||||
|
||||
const user: SparkUser = {
|
||||
id: 1,
|
||||
flarumUserId: "123",
|
||||
username: "momen",
|
||||
displayName: "Momen",
|
||||
avatarUrl: "https://bbs.spark-app.store/avatar.png",
|
||||
forumLevel: "管理员",
|
||||
forumGroups: ["管理员"],
|
||||
};
|
||||
|
||||
const download: DownloadedAppRecord = {
|
||||
id: 1,
|
||||
appKey: "app:office:wps",
|
||||
pkgname: "wps",
|
||||
name: "WPS",
|
||||
category: "office",
|
||||
selectedOrigin: "apm",
|
||||
version: "1.0.0",
|
||||
packageArch: "amd64",
|
||||
downloadedAt: "2026-05-18T00:00:00Z",
|
||||
};
|
||||
|
||||
describe("UserManagementView", () => {
|
||||
it("renders profile, forum level, links, downloads, and sync preference", () => {
|
||||
render(UserManagementView, {
|
||||
props: {
|
||||
user,
|
||||
downloadedApps: [download],
|
||||
syncEnabled: true,
|
||||
loading: false,
|
||||
error: "",
|
||||
syncing: false,
|
||||
syncMessage: "",
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByText("Momen")).toBeTruthy();
|
||||
expect(screen.getByText("管理员")).toBeTruthy();
|
||||
expect(screen.getByText("论坛首页")).toBeTruthy();
|
||||
expect(screen.getByText("修改论坛资料")).toBeTruthy();
|
||||
expect(screen.getByText("WPS")).toBeTruthy();
|
||||
expect(screen.getByLabelText("自动同步已安装应用")).toBeChecked();
|
||||
});
|
||||
|
||||
it("shows manual sync progress and result feedback", async () => {
|
||||
const { rerender } = render(UserManagementView, {
|
||||
props: {
|
||||
user,
|
||||
downloadedApps: [],
|
||||
syncEnabled: false,
|
||||
loading: false,
|
||||
error: "",
|
||||
syncing: true,
|
||||
syncMessage: "",
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByRole("button", { name: "同步中..." })).toBeDisabled();
|
||||
|
||||
await rerender({ syncing: false, syncMessage: "同步完成" });
|
||||
|
||||
expect(screen.getByText("同步完成")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the forum profile cover when available", () => {
|
||||
render(UserManagementView, {
|
||||
props: {
|
||||
user: {
|
||||
...user,
|
||||
coverUrl:
|
||||
"https://bbs.spark-app.store/assets/covers/JizZCVjiSFASrEfp.jpg",
|
||||
},
|
||||
downloadedApps: [],
|
||||
syncEnabled: false,
|
||||
loading: false,
|
||||
error: "",
|
||||
syncing: false,
|
||||
syncMessage: "",
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("profile-cover")).toHaveStyle({
|
||||
backgroundImage:
|
||||
'url("https://bbs.spark-app.store/assets/covers/JizZCVjiSFASrEfp.jpg")',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/vue";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import WindowTitleBar from "@/components/WindowTitleBar.vue";
|
||||
|
||||
const windowControls = {
|
||||
minimize: vi.fn(),
|
||||
toggleMaximize: vi.fn(),
|
||||
close: vi.fn(),
|
||||
};
|
||||
|
||||
describe("WindowTitleBar", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
Object.defineProperty(window, "windowControls", {
|
||||
value: windowControls,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("sends window control requests", async () => {
|
||||
render(WindowTitleBar);
|
||||
|
||||
await fireEvent.click(screen.getByRole("button", { name: "最小化" }));
|
||||
await fireEvent.click(screen.getByRole("button", { name: "最大化或还原" }));
|
||||
await fireEvent.click(screen.getByRole("button", { name: "关闭" }));
|
||||
|
||||
expect(windowControls.minimize).toHaveBeenCalledTimes(1);
|
||||
expect(windowControls.toggleMaximize).toHaveBeenCalledTimes(1);
|
||||
expect(windowControls.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("stays below modal overlays in the stacking order", () => {
|
||||
const { container } = render(WindowTitleBar);
|
||||
|
||||
expect(container.firstElementChild?.className).toContain("z-20");
|
||||
expect(container.firstElementChild?.className).not.toContain("z-[60]");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildAccountFrameUrl } from "@/modules/accountCenterUrl";
|
||||
|
||||
describe("accountCenterUrl", () => {
|
||||
it("falls back to the production account URL when configured URL is malformed", () => {
|
||||
const url = buildAccountFrameUrl("not a url", "momen");
|
||||
|
||||
expect(url).toContain("https://account.spark-app.store/account");
|
||||
expect(url).toContain("view=management");
|
||||
expect(url).toContain("user=momen");
|
||||
expect(url).not.toMatch(/token|jwt|password|access/i);
|
||||
});
|
||||
|
||||
it("falls back when configured URL uses an unsafe protocol", () => {
|
||||
const url = buildAccountFrameUrl("javascript:alert(1)", "momen");
|
||||
|
||||
expect(url).toContain("https://account.spark-app.store/account");
|
||||
expect(url).toContain("view=management");
|
||||
expect(url).toContain("user=momen");
|
||||
expect(url).not.toMatch(/^javascript:/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("accountSyncState", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("scopes installed sync preference to the current user", async () => {
|
||||
const {
|
||||
installedSyncEnabled,
|
||||
loadInstalledSyncPreference,
|
||||
setInstalledSyncEnabled,
|
||||
} = await import("@/global/accountSyncState");
|
||||
|
||||
loadInstalledSyncPreference(1);
|
||||
setInstalledSyncEnabled(true);
|
||||
|
||||
loadInstalledSyncPreference(2);
|
||||
|
||||
expect(installedSyncEnabled.value).toBeNull();
|
||||
|
||||
setInstalledSyncEnabled(false);
|
||||
loadInstalledSyncPreference(1);
|
||||
|
||||
expect(installedSyncEnabled.value).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
FLARUM_BASE_URL,
|
||||
FLARUM_REGISTER_URL,
|
||||
SPARK_BACKEND_BASE_URL,
|
||||
} from "@/global/storeConfig";
|
||||
import type {
|
||||
DownloadedAppRecord,
|
||||
FavoriteFolder,
|
||||
FavoriteItem,
|
||||
ReviewTags,
|
||||
SparkUser,
|
||||
SyncedAppListItem,
|
||||
} from "@/global/typedefinition";
|
||||
|
||||
const productionEnv = readFileSync(
|
||||
resolve(dirname(fileURLToPath(import.meta.url)), "../../../.env.production"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
describe("account shared types", () => {
|
||||
it("points production account requests at the public account service", () => {
|
||||
expect(productionEnv).toContain(
|
||||
"VITE_SPARK_BACKEND_BASE_URL=https://account.spark-app.store",
|
||||
);
|
||||
});
|
||||
|
||||
it("exports backend/forum config and account shapes", () => {
|
||||
const user: SparkUser = {
|
||||
id: 1,
|
||||
flarumUserId: "123",
|
||||
username: "momen",
|
||||
displayName: "Momen",
|
||||
avatarUrl: "https://bbs.spark-app.store/avatar.png",
|
||||
coverUrl: "https://bbs.spark-app.store/assets/covers/user.jpg",
|
||||
forumLevel: "管理员",
|
||||
forumGroups: ["管理员"],
|
||||
};
|
||||
const folder: FavoriteFolder = {
|
||||
id: 1,
|
||||
name: "默认收藏夹",
|
||||
itemCount: 1,
|
||||
createdAt: "2026-05-18T00:00:00Z",
|
||||
updatedAt: "2026-05-18T00:00:00Z",
|
||||
};
|
||||
const favorite: FavoriteItem = {
|
||||
id: 2,
|
||||
appKey: "app:office:wps",
|
||||
pkgname: "wps",
|
||||
name: "WPS",
|
||||
category: "office",
|
||||
iconUrl: "https://example.invalid/wps.png",
|
||||
createdAt: "2026-05-18T00:00:00Z",
|
||||
};
|
||||
const download: DownloadedAppRecord = {
|
||||
id: 3,
|
||||
appKey: "app:office:wps",
|
||||
pkgname: "wps",
|
||||
name: "WPS",
|
||||
category: "office",
|
||||
selectedOrigin: "apm",
|
||||
version: "1.0.0",
|
||||
packageArch: "amd64",
|
||||
downloadedAt: "2026-05-18T00:00:00Z",
|
||||
};
|
||||
const syncItem: SyncedAppListItem = {
|
||||
pkgname: "wps",
|
||||
origin: "apm",
|
||||
category: "office",
|
||||
version: "1.0.0",
|
||||
packageArch: "amd64",
|
||||
appName: "WPS",
|
||||
iconUrl: "https://example.invalid/wps.png",
|
||||
};
|
||||
const tags: ReviewTags = {
|
||||
origin: "apm",
|
||||
category: "office",
|
||||
pkgname: "wps",
|
||||
version: "1.0.0",
|
||||
packageArch: "amd64",
|
||||
clientArch: "amd64",
|
||||
distro: "deepin 25",
|
||||
};
|
||||
|
||||
expect(typeof SPARK_BACKEND_BASE_URL).toBe("string");
|
||||
expect(SPARK_BACKEND_BASE_URL).toMatch(/^https?:\/\//);
|
||||
expect(FLARUM_BASE_URL).toContain("bbs.spark-app.store");
|
||||
expect(FLARUM_REGISTER_URL).toContain("register");
|
||||
expect(user.forumGroups).toEqual(["管理员"]);
|
||||
expect(user.coverUrl).toContain("/assets/covers/");
|
||||
expect(folder.itemCount).toBe(1);
|
||||
expect(favorite.appKey).toBe("app:office:wps");
|
||||
expect(download.selectedOrigin).toBe("apm");
|
||||
expect(syncItem.origin).toBe("apm");
|
||||
expect(tags.packageArch).toBe("amd64");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildFavoriteAppKey,
|
||||
buildReviewAppKey,
|
||||
buildReviewTags,
|
||||
getDisplayApp,
|
||||
parsePackageArch,
|
||||
} from "@/modules/appIdentity";
|
||||
import type { App } from "@/global/typedefinition";
|
||||
|
||||
const app: App = {
|
||||
name: "WPS",
|
||||
pkgname: "wps",
|
||||
version: "1.0.0",
|
||||
filename: "wps_1.0.0_amd64.deb",
|
||||
torrent_address: "",
|
||||
author: "",
|
||||
contributor: "",
|
||||
website: "",
|
||||
update: "",
|
||||
size: "",
|
||||
more: "",
|
||||
tags: "",
|
||||
img_urls: [],
|
||||
icons: "",
|
||||
category: "office",
|
||||
origin: "apm",
|
||||
currentStatus: "not-installed",
|
||||
};
|
||||
|
||||
describe("appIdentity", () => {
|
||||
it("builds favorite and review keys", () => {
|
||||
expect(buildFavoriteAppKey(app)).toBe("app:office:wps");
|
||||
expect(buildReviewAppKey(app, "amd64")).toBe("apm:amd64-apm:office:wps");
|
||||
});
|
||||
|
||||
it("builds review keys from already-qualified client arch values", () => {
|
||||
expect(buildReviewAppKey({ ...app, origin: "spark" }, "amd64-store")).toBe(
|
||||
"spark:amd64-store:office:wps",
|
||||
);
|
||||
expect(buildReviewAppKey(app, "amd64-apm")).toBe(
|
||||
"apm:amd64-apm:office:wps",
|
||||
);
|
||||
});
|
||||
|
||||
it("parses package arch and review tags", () => {
|
||||
expect(parsePackageArch(app.filename)).toBe("amd64");
|
||||
expect(
|
||||
buildReviewTags(app, { clientArch: "amd64", distro: "deepin 25" }),
|
||||
).toMatchObject({
|
||||
origin: "apm",
|
||||
category: "office",
|
||||
pkgname: "wps",
|
||||
packageArch: "amd64",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns selected display app from merged apps", () => {
|
||||
const merged: App = {
|
||||
...app,
|
||||
isMerged: true,
|
||||
viewingOrigin: "spark",
|
||||
sparkApp: { ...app, origin: "spark" },
|
||||
apmApp: app,
|
||||
};
|
||||
expect(getDisplayApp(merged)?.origin).toBe("spark");
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user