mirror of
https://gitee.com/spark-store-project/spark-store
synced 2026-08-06 14:43:56 +08:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8e94c87bc | ||
|
|
e97052b93c |
@@ -1,4 +1,3 @@
|
||||
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,3 +1,2 @@
|
||||
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
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
name: Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags:
|
||||
- "*"
|
||||
paths-ignore:
|
||||
- "**.md"
|
||||
- "**.spec.js"
|
||||
- ".idea"
|
||||
- ".vscode"
|
||||
- ".dockerignore"
|
||||
- "Dockerfile"
|
||||
- ".gitignore"
|
||||
- ".github/**"
|
||||
- "!.github/workflows/build.yml"
|
||||
- "!.github/workflows/test.yml"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- "**.md"
|
||||
- "**.spec.js"
|
||||
- ".idea"
|
||||
- ".vscode"
|
||||
- ".dockerignore"
|
||||
- "Dockerfile"
|
||||
- ".gitignore"
|
||||
- ".github/**"
|
||||
- "!.github/workflows/build.yml"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Run tests
|
||||
run: npm run test
|
||||
|
||||
- name: Run lint
|
||||
run: npm run lint
|
||||
|
||||
build:
|
||||
needs: test
|
||||
runs-on: ${{ matrix.os }}
|
||||
container: ${{ matrix.docker_image }}
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest]
|
||||
package: [deb, rpm]
|
||||
architecture: [x64, arm64]
|
||||
include:
|
||||
- package: deb
|
||||
docker_image: "debian:12"
|
||||
- package: rpm
|
||||
docker_image: "almalinux:8"
|
||||
|
||||
steps:
|
||||
- name: Install Build Dependencies
|
||||
if: matrix.package == 'deb'
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y curl git wget devscripts fakeroot equivs lintian python3
|
||||
apt-get install -y build-essential
|
||||
|
||||
- name: Install Build Dependencies
|
||||
if: matrix.package == 'rpm'
|
||||
run: |
|
||||
dnf install -y curl git wget rpm-build rpmdevtools rpmlint python3
|
||||
dnf group install -y "Development Tools"
|
||||
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
npm install
|
||||
|
||||
- name: Download host-spawn
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "${{ matrix.architecture }}" == "x64" ]; then
|
||||
curl -fsSL -o ./extras/host-spawn https://github.com/1player/host-spawn/releases/latest/download/host-spawn-x86_64
|
||||
elif [ "${{ matrix.architecture }}" == "arm64" ]; then
|
||||
curl -fsSL -o ./extras/host-spawn https://github.com/1player/host-spawn/releases/latest/download/host-spawn-aarch64
|
||||
fi
|
||||
chmod +x ./extras/host-spawn
|
||||
|
||||
- name: Build Release Files
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "${{ matrix.package }}" == "deb" ]; then
|
||||
npm run build:deb -- --${{ matrix.architecture }}
|
||||
elif [ "${{ matrix.package }}" == "rpm" ]; then
|
||||
npm run build:rpm -- --${{ matrix.architecture }}
|
||||
fi
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: release_for_${{ matrix.package }}_${{ matrix.architecture }}
|
||||
path: release/**/*.${{ matrix.package }}
|
||||
retention-days: 5
|
||||
|
||||
release:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Upload to GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ github.ref_name }}
|
||||
files: |
|
||||
artifacts/**/*.deb
|
||||
artifacts/**/*.rpm
|
||||
generate_release_notes: true
|
||||
@@ -0,0 +1,83 @@
|
||||
name: Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
|
||||
jobs:
|
||||
unit-tests:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Run unit tests
|
||||
run: npm run test -- --coverage
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
files: ./coverage/lcov.info
|
||||
flags: unittests
|
||||
name: codecov-umbrella
|
||||
|
||||
e2e-tests:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Run E2E tests
|
||||
run: xvfb-run npm run test:e2e
|
||||
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Run ESLint
|
||||
run: npm run lint
|
||||
|
||||
- name: Check formatting
|
||||
run: npm run format -- --check
|
||||
-25
@@ -14,17 +14,6 @@ dist-electron
|
||||
release
|
||||
*.local
|
||||
|
||||
# Nix build outputs
|
||||
/result
|
||||
/result-*
|
||||
|
||||
# Local secrets and databases
|
||||
.env
|
||||
.env.*.local
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
*.db
|
||||
|
||||
# Test coverage
|
||||
coverage
|
||||
.nyc_output
|
||||
@@ -51,17 +40,3 @@ yarn.lock
|
||||
|
||||
test-results.json
|
||||
.worktrees/
|
||||
.superpowers/
|
||||
|
||||
# VSCode CMake Extension
|
||||
build/*
|
||||
|
||||
# Debian build intermediates
|
||||
debian/*.log
|
||||
debian/*.substvars
|
||||
debian/*.debhelper
|
||||
debian/debhelper-build-stamp
|
||||
debian/files
|
||||
debian/tmp/
|
||||
debian/.debhelper/
|
||||
debian/spark-store/
|
||||
|
||||
Vendored
-1
@@ -30,7 +30,6 @@
|
||||
// },
|
||||
"runtimeArgs": [
|
||||
"--remote-debugging-port=9229",
|
||||
"--no-sandbox",
|
||||
"."
|
||||
],
|
||||
"envFile": "${workspaceFolder}/.vscode/.debug.env",
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
.DEFAULT_GOAL := build
|
||||
|
||||
clean:
|
||||
rm -rf release/
|
||||
rm -f lightningcss-linux-loong64-gnu.tgz
|
||||
rm -f tailwindcss-oxide-linux-loong64-gnu.tgz
|
||||
|
||||
build:
|
||||
ifeq (${DEB_HOST_ARCH},loong64)
|
||||
# Install oxide for loongarch64
|
||||
curl -fsSL https://github.com/loong64/tailwindcss/releases/download/v4.3.2/tailwindcss-oxide-linux-loong64-gnu.tgz -o tailwindcss-oxide-linux-loong64-gnu.tgz
|
||||
mkdir -p $(CURDIR)/node_modules/@tailwindcss/oxide-linux-loong64-gnu
|
||||
tar xf tailwindcss-oxide-linux-loong64-gnu.tgz -C $(CURDIR)/node_modules/@tailwindcss/oxide-linux-loong64-gnu --strip-components 1
|
||||
rm tailwindcss-oxide-linux-loong64-gnu.tgz
|
||||
# Install lightningcss-linux-loong64-gnu for loongarch64
|
||||
curl -fsSL https://github.com/loong64/lightningcss/releases/download/v1.32.0/lightningcss-linux-loong64-gnu-1.32.0.tgz -o lightningcss-linux-loong64-gnu.tgz
|
||||
mkdir -p $(CURDIR)/node_modules/lightningcss-linux-loong64-gnu
|
||||
tar xf lightningcss-linux-loong64-gnu.tgz -C $(CURDIR)/node_modules/lightningcss-linux-loong64-gnu --strip-components 1
|
||||
rm lightningcss-linux-loong64-gnu.tgz
|
||||
npm run build:loong64
|
||||
else
|
||||
npm run build
|
||||
endif
|
||||
|
||||
install:
|
||||
mkdir -p $(DESTDIR)/opt/spark-store/bin/
|
||||
mkdir -p $(DESTDIR)/opt/spark-store/extras/
|
||||
mkdir -p $(DESTDIR)/opt/durapps/spark-store/bin/
|
||||
mkdir -p $(DESTDIR)/usr/share/icons/
|
||||
mkdir -p $(DESTDIR)/usr/lib/
|
||||
mkdir -p $(DESTDIR)/usr/bin/
|
||||
mkdir -p $(DESTDIR)/etc/apt/
|
||||
mkdir -p $(DESTDIR)/lib/systemd/
|
||||
mkdir -p $(DESTDIR)/tmp/
|
||||
cp -rv release/*/linux*-unpacked/* $(DESTDIR)/opt/spark-store/bin/
|
||||
cp -rv release/*/linux*-unpacked/extras/* $(DESTDIR)/opt/spark-store/extras/
|
||||
cp -rv tool/* $(DESTDIR)/opt/durapps/spark-store/bin/
|
||||
cp -rv pkg/usr/share/fish/ $(DESTDIR)/usr/share/
|
||||
cp -rv icons/hicolor/ $(DESTDIR)/usr/share/icons/
|
||||
cp -rv pkg/usr/share/icons/hicolor/ $(DESTDIR)/usr/share/icons/
|
||||
cp -rv pkg/usr/lib/systemd $(DESTDIR)/usr/lib/
|
||||
cp -rv pkg/usr/share/applications/ $(DESTDIR)/usr/share/
|
||||
cp -rv pkg/usr/share/polkit-1 $(DESTDIR)/usr/share/
|
||||
cp -rv pkg/usr/share/aptss $(DESTDIR)/usr/share/
|
||||
cp -rv pkg/usr/share/ssinstall/ $(DESTDIR)/usr/share/
|
||||
cp -rv pkg/usr/share/ssinstall-local/ $(DESTDIR)/usr/share/
|
||||
cp -rv pkg/usr/share/dsg/ $(DESTDIR)/usr/share/
|
||||
cp -rv pkg/usr/share/bash-completion/ $(DESTDIR)/usr/share/
|
||||
cp -rv tool/spark-store.asc $(DESTDIR)/opt/durapps/spark-store/bin/
|
||||
ln -s ../../../spark-store/extras/spark-store $(DESTDIR)/opt/durapps/spark-store/bin/spark-store
|
||||
@@ -18,7 +18,7 @@ Linux 应用的数量相对有限,Wine 软件的可获取性也颇为困难。
|
||||
|
||||
**当前支持的 Linux 发行版包括(但不限于):**
|
||||
|
||||
- **amd64 架构:** Debian 10+ / Ubuntu 22.04+ / Arch Linux / Fedora / deepin / UOS / 银河麒麟 / NixOS
|
||||
- **amd64 架构:** Debian 10+ / Ubuntu 22.04+ / Arch Linux / Fedora / deepin / UOS / 银河麒麟
|
||||
- **arm64 架构:** Debian 10+ / Ubuntu 22.04+ / Arch Linux / deepin / UOS / 银河麒麟
|
||||
- **loong64 架构:** deepin 23/25
|
||||
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
# 侧边栏入口配置 (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
@@ -1,5 +0,0 @@
|
||||
spark-store (5.2.1.0) 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
-39
@@ -1,39 +0,0 @@
|
||||
Source: spark-store
|
||||
Section: utils
|
||||
Priority: optional
|
||||
Maintainer: shenmo <shenmo@spark-app.store>
|
||||
Rules-Requires-Root: no
|
||||
Build-Depends:
|
||||
debhelper-compat (= 13),
|
||||
make,
|
||||
Standards-Version: 4.7.2
|
||||
Homepage: https://www.spark-app.store/
|
||||
|
||||
Package: spark-store
|
||||
Architecture: any
|
||||
Provides: spark-store-console-in-container
|
||||
Depends:
|
||||
${shlibs:Depends},
|
||||
${misc:Depends},
|
||||
libgtk-3-0,
|
||||
libnotify4,
|
||||
libnss3,
|
||||
libxss1,
|
||||
libxtst6,
|
||||
xdg-utils,
|
||||
libatspi2.0-0,
|
||||
libuuid1,
|
||||
libsecret-1-0,
|
||||
xdg-utils,
|
||||
shared-mime-info,
|
||||
aria2,
|
||||
gnupg,
|
||||
zenity,
|
||||
policykit-1 | pkexec,
|
||||
libnotify-bin,
|
||||
desktop-file-utils,
|
||||
lsb-release,
|
||||
systemd,
|
||||
curl
|
||||
Description: Spark Store
|
||||
A community powered app store, powered by APM.
|
||||
Vendored
-78
@@ -1,78 +0,0 @@
|
||||
#!/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
|
||||
Vendored
-7
@@ -1,7 +0,0 @@
|
||||
#!/bin/bash
|
||||
rm -fv /usr/share/polkit-1/actions/store.spark-app.spark-store.policy
|
||||
|
||||
# Update certain caches
|
||||
update-icon-caches /usr/share/icons/hicolor || true
|
||||
update-desktop-database /usr/share/applications || true
|
||||
update-mime-database /usr/share/mime || true
|
||||
Vendored
-28
@@ -1,28 +0,0 @@
|
||||
#!/bin/bash
|
||||
#检测网络链接畅通
|
||||
function network-check()
|
||||
{
|
||||
#超时时间
|
||||
local timeout=15
|
||||
|
||||
#目标网站
|
||||
local target=www.baidu.com
|
||||
|
||||
#获取响应状态码
|
||||
local ret_code=`curl -I -s --connect-timeout ${timeout} ${target} -w %{http_code} | tail -n1`
|
||||
|
||||
if [ "x$ret_code" = "x200" ]; then
|
||||
echo "Network Checked successful ! Continue..."
|
||||
echo "网络通畅,继续安装"
|
||||
else
|
||||
#网络不畅通
|
||||
echo "Network failed ! Cancel the installation"
|
||||
echo "网络不畅,终止安装"
|
||||
exit -1
|
||||
fi
|
||||
|
||||
}
|
||||
|
||||
|
||||
#network-check
|
||||
echo "不再检测网络"
|
||||
Vendored
-64
@@ -1,64 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
function notify-send()
|
||||
{
|
||||
# Detect the user using such display
|
||||
local user=$(who | awk '{print $1}' | head -n 1)
|
||||
|
||||
# Detect the id of the user
|
||||
local uid=$(id -u $user)
|
||||
|
||||
sudo -u $user DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$uid/bus notify-send "$@"
|
||||
}
|
||||
|
||||
if [ "$1" = "remove" -o "$1" = "purge" ] ; then
|
||||
echo "$1"
|
||||
echo "卸载操作,进行配置清理"
|
||||
|
||||
# Remove residual symbol links
|
||||
unlink /usr/local/bin/spark-store
|
||||
unlink /usr/local/bin/ssinstall
|
||||
unlink /usr/local/bin/ssaudit
|
||||
unlink /usr/bin/ssinstall
|
||||
unlink /usr/bin/ssaudit
|
||||
unlink /usr/local/bin/spark-dstore-patch
|
||||
unlink /usr/bin/spark-dstore-patch
|
||||
unlink /usr/local/bin/ss-apt-fast
|
||||
unlink /usr/bin/aptss
|
||||
|
||||
rm -rf /etc/aptss/
|
||||
rm -rf /var/lib/aptss/
|
||||
|
||||
# Remove residual symbol links to stop upgrade detect
|
||||
rm -f /etc/xdg/autostart/spark-update-notifier.desktop
|
||||
# Remove config files
|
||||
for username in `ls /home`
|
||||
do
|
||||
echo /home/$username
|
||||
if [ -d /home/$username/.config/spark-union/spark-store ]
|
||||
then
|
||||
rm -rf /home/$username/.config/spark-union/spark-store
|
||||
fi
|
||||
done
|
||||
|
||||
|
||||
# Shutdown services
|
||||
systemctl stop spark-update-notifier
|
||||
# Stop update detect service
|
||||
systemctl disable spark-update-notifier
|
||||
|
||||
|
||||
|
||||
# Remove gpg key file
|
||||
rm -f /etc/apt/trusted.gpg.d/spark-store.gpg
|
||||
apt-key del '9D9A A859 F750 24B1 A1EC E16E 0E41 D354 A29A 440C' || true
|
||||
else
|
||||
|
||||
if [ ! -z "`pidof spark-store`" ] ; then
|
||||
echo "关闭已有 spark-store.."
|
||||
notify-send "正在升级星火商店" "请在升级结束后重启星火商店" -i spark-store
|
||||
killall spark-store
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
Vendored
-34
@@ -1,34 +0,0 @@
|
||||
#!/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
@@ -1 +0,0 @@
|
||||
3.0 (quilt)
|
||||
Vendored
-2
@@ -1,2 +0,0 @@
|
||||
interest-noawait /opt/apps
|
||||
|
||||
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
@@ -1,150 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,141 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,284 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,315 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,374 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,43 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,59 +0,0 @@
|
||||
# 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.
|
||||
@@ -26,7 +26,6 @@ linux:
|
||||
Categories: "System;"
|
||||
mimeTypes:
|
||||
- "x-scheme-handler/spk"
|
||||
- "x-scheme-handler/apt"
|
||||
target:
|
||||
- "AppImage"
|
||||
- "deb"
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { ipcMain, WebContents } from "electron";
|
||||
import { spawn, ChildProcess } from "node:child_process";
|
||||
import { spawn, ChildProcess, exec } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import fs from "node:fs";
|
||||
import { promises as fsp } from "node:fs";
|
||||
import os from "node:os";
|
||||
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" });
|
||||
|
||||
@@ -43,142 +41,31 @@ type InstallTask = {
|
||||
filename?: string;
|
||||
origin: "spark" | "apm";
|
||||
cancelled?: boolean;
|
||||
phase: "queued-download" | "downloading" | "queued-install" | "installing";
|
||||
};
|
||||
|
||||
const SHELL_CALLER_PATH = "/opt/spark-store/extras/shell-caller.sh";
|
||||
|
||||
// 以下路径配置参考自index.ts并且与其保持一致
|
||||
// 其中,SPARK_CONFIG_DIR为配置目录,若此目录下出现ssshell-config-do-not-create-desktop文件
|
||||
// 则代表「关闭『自动创建桌面启动器』功能」
|
||||
const SPARK_CONFIG_DIR = path.join(
|
||||
os.homedir(),
|
||||
".config/spark-union/spark-store",
|
||||
);
|
||||
const CREATE_DESKTOP_CONFIG = "ssshell-config-do-not-create-desktop";
|
||||
const CREATE_DESKTOP_CONFIG_PATH = path.join(
|
||||
SPARK_CONFIG_DIR,
|
||||
CREATE_DESKTOP_CONFIG,
|
||||
);
|
||||
|
||||
// APM应用的.desktop文件可能在以下几个位置
|
||||
const APM_DESKTOP_ENTRY_DIRS = [
|
||||
"/var/lib/apm", // 实体机/宿主系统
|
||||
"/var/lib/apm/apm/files/ace-env/var/lib/apm", // ACE容器
|
||||
];
|
||||
|
||||
// Helper: 用XDG_DESKTOP_DIR拿桌面路径,读不到我就回退到~/Desktop
|
||||
const resolveDesktopDir = async (): Promise<string> => {
|
||||
const userDirsPath = path.join(os.homedir(), ".config", "user-dirs.dirs");
|
||||
|
||||
try {
|
||||
const content = await fsp.readFile(userDirsPath, "utf-8");
|
||||
const matchRes = content.match(/^XDG_DESKTOP_DIR="\$HOME\/(.+)"$/m);
|
||||
if (matchRes?.[1]) {
|
||||
return path.join(os.homedir(), matchRes[1]);
|
||||
}
|
||||
} catch {
|
||||
// user-dirs.dirs无法读取
|
||||
logger.warn(
|
||||
`Failed to get XDG_DESKTOP_DIR, I'm falling back to ~/Desktop!!`,
|
||||
);
|
||||
}
|
||||
return path.join(os.homedir(), "Desktop");
|
||||
};
|
||||
|
||||
// Helper: 为APM安装的应用创建桌面快捷方式(如果启用了「自动创建桌面启动器」)
|
||||
const createApmDesktopShortcut = async (
|
||||
pkgname: string,
|
||||
sendLog: (msg: string) => void,
|
||||
) => {
|
||||
// 如上所述,配置目录里面有ssshell-config-do-not-create-desktop文件就是功能关闭
|
||||
try {
|
||||
await fsp.access(CREATE_DESKTOP_CONFIG_PATH);
|
||||
logger.debug(
|
||||
`Desktop shortcut creation has been disabled. Skipping creating it for ${pkgname}.`,
|
||||
);
|
||||
return;
|
||||
} catch {
|
||||
// 文件不存在就是功能启用 继续
|
||||
}
|
||||
|
||||
// 解析桌面路径,确保目录存在
|
||||
const desktopDir = await resolveDesktopDir();
|
||||
try {
|
||||
await fsp.mkdir(desktopDir, { recursive: true });
|
||||
} catch (err) {
|
||||
logger.warn(`Failed to create desktop directory ${desktopDir}: ${err}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 遍历APM应用的.desktop文件可能在以下几个位置
|
||||
for (const baseDir of APM_DESKTOP_ENTRY_DIRS) {
|
||||
const entriesPath = path.join(baseDir, pkgname, "entries", "applications");
|
||||
let files: string[];
|
||||
try {
|
||||
files = await fsp.readdir(entriesPath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
// 忽略扩展名不符的
|
||||
if (!file.endsWith(".desktop")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const srcPath = path.join(entriesPath, file);
|
||||
const destPath = path.join(desktopDir, file);
|
||||
|
||||
// 目标已存在则跳过,继续检查下一个
|
||||
try {
|
||||
await fsp.access(destPath);
|
||||
logger.debug(`Shortcut already exists: ${destPath}`);
|
||||
sendLog(`Shortcut already exists: ${file}`);
|
||||
continue;
|
||||
} catch {
|
||||
// 不存在,继续
|
||||
}
|
||||
|
||||
try {
|
||||
// 读取.desktop文件内容
|
||||
const content = await fsp.readFile(srcPath, "utf-8");
|
||||
|
||||
// 写入用户桌面,顺带处理一下权限问题
|
||||
await fsp.writeFile(destPath, content, { mode: 0o644 });
|
||||
sendLog(`Wrote desktop shortcut: ${file}`);
|
||||
logger.info(`Wrote shortcut ${destPath} for ${pkgname}.`);
|
||||
return;
|
||||
} catch (err) {
|
||||
logger.warn(`Failed to create desktop shortcut for ${pkgname}: ${err}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(`Could NOT find ${pkgname}'s .desktop file...`);
|
||||
};
|
||||
|
||||
export const tasks = new Map<number, InstallTask>();
|
||||
|
||||
// 下载与安装分离:最多 5 个并发下载,安装一次只允许一个
|
||||
const MAX_CONCURRENT_DOWNLOADS = 5;
|
||||
let activeDownloadCount = 0;
|
||||
let installIdle = true;
|
||||
let idle = true; // Indicates if the installation manager is idle
|
||||
|
||||
export const checkSuperUserCommand = async (): Promise<string> => {
|
||||
if (process.getuid?.() === 0) return "";
|
||||
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();
|
||||
|
||||
for (const command of SUPER_USER_COMMAND_CANDIDATES) {
|
||||
const superUserCmd = await findExecutable(command);
|
||||
if (superUserCmd.length > 0) {
|
||||
logger.info(`找到提升权限命令: ${superUserCmd}`);
|
||||
return superUserCmd;
|
||||
if (superUserCmd.length === 0) {
|
||||
logger.error("没有找到提升权限的命令 pkexec!");
|
||||
}
|
||||
}
|
||||
|
||||
logger.error("没有找到提升权限的命令 pkexec!");
|
||||
return "";
|
||||
return superUserCmd;
|
||||
};
|
||||
|
||||
const runCommandCapture = async (execCommand: string, execParams: string[]) => {
|
||||
@@ -376,95 +263,72 @@ ipcMain.on("queue-install", async (event, download_json) => {
|
||||
metalinkUrl,
|
||||
filename,
|
||||
origin: origin || "apm",
|
||||
phase: metalinkUrl ? "queued-download" : "queued-install",
|
||||
};
|
||||
tasks.set(id, task);
|
||||
processNextDownload();
|
||||
processNextInstall();
|
||||
if (idle) processNextInQueue();
|
||||
});
|
||||
|
||||
// Cancel Handler
|
||||
ipcMain.on("cancel-install", (event, id) => {
|
||||
const task = tasks.get(id);
|
||||
if (!task) return;
|
||||
if (tasks.has(id)) {
|
||||
const task = tasks.get(id);
|
||||
if (task) {
|
||||
task.cancelled = true;
|
||||
task.download_process?.kill();
|
||||
task.install_process?.kill();
|
||||
logger.info(`已取消任务: ${id}`);
|
||||
|
||||
task.cancelled = true;
|
||||
logger.info(`已取消任务: ${id}`);
|
||||
// 删除下载目录
|
||||
if (task.downloadDir && fs.existsSync(task.downloadDir)) {
|
||||
try {
|
||||
fs.rmSync(task.downloadDir, { recursive: true, force: true });
|
||||
logger.info(`已删除下载目录: ${task.downloadDir}`);
|
||||
} catch (err) {
|
||||
logger.error(`删除下载目录失败 ${task.downloadDir}: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 删除下载目录
|
||||
if (task.downloadDir && fs.existsSync(task.downloadDir)) {
|
||||
try {
|
||||
fs.rmSync(task.downloadDir, { recursive: true, force: true });
|
||||
logger.info(`已删除下载目录: ${task.downloadDir}`);
|
||||
} catch (err) {
|
||||
logger.error(`删除下载目录失败 ${task.downloadDir}: ${err}`);
|
||||
// 主动发送完成(失败)事件,close 回调会因 cancelled 标志跳过
|
||||
task.webContents?.send("install-complete", {
|
||||
id,
|
||||
success: false,
|
||||
time: Date.now(),
|
||||
exitCode: -1,
|
||||
message: JSON.stringify({
|
||||
message: "用户取消",
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
}),
|
||||
});
|
||||
|
||||
tasks.delete(id);
|
||||
idle = true;
|
||||
if (tasks.size > 0) processNextInQueue();
|
||||
}
|
||||
}
|
||||
|
||||
// 主动发送完成(失败)事件
|
||||
task.webContents?.send("install-complete", {
|
||||
id,
|
||||
success: false,
|
||||
time: Date.now(),
|
||||
exitCode: -1,
|
||||
message: JSON.stringify({
|
||||
message: "用户取消",
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
}),
|
||||
});
|
||||
|
||||
const isRunning = task.phase === "downloading" || task.phase === "installing";
|
||||
|
||||
if (isRunning) {
|
||||
// 运行中的任务:终止进程,由对应的阶段处理器在 finally 中清理计数器与队列
|
||||
task.download_process?.kill();
|
||||
task.install_process?.kill();
|
||||
} else {
|
||||
// 排队中的任务(未开始执行):直接清理并调度
|
||||
tasks.delete(id);
|
||||
processNextDownload();
|
||||
processNextInstall();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 尝试启动排队中的下载任务,最多同时运行 MAX_CONCURRENT_DOWNLOADS 个。
|
||||
*/
|
||||
function processNextDownload() {
|
||||
while (activeDownloadCount < MAX_CONCURRENT_DOWNLOADS) {
|
||||
const task = Array.from(tasks.values()).find(
|
||||
(t) => t.phase === "queued-download" && !t.cancelled,
|
||||
);
|
||||
if (!task) break;
|
||||
task.phase = "downloading";
|
||||
activeDownloadCount++;
|
||||
void runDownloadPhase(task);
|
||||
}
|
||||
}
|
||||
async function processNextInQueue() {
|
||||
if (!idle) return;
|
||||
|
||||
/**
|
||||
* 尝试启动排队中的安装任务,安装一次只允许一个。
|
||||
*/
|
||||
function processNextInstall() {
|
||||
if (!installIdle) return;
|
||||
const task = Array.from(tasks.values()).find(
|
||||
(t) => t.phase === "queued-install" && !t.cancelled,
|
||||
);
|
||||
// Always take the first task to ensure sequence
|
||||
const task = Array.from(tasks.values())[0];
|
||||
if (!task) {
|
||||
installIdle = true;
|
||||
idle = true;
|
||||
return;
|
||||
}
|
||||
installIdle = false;
|
||||
task.phase = "installing";
|
||||
void runInstallPhase(task);
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载阶段:获取 Metalink → aria2c 下载(含重试)。
|
||||
* 下载完成后任务进入 queued-install 等待安装。
|
||||
*/
|
||||
async function runDownloadPhase(task: InstallTask) {
|
||||
// 如果任务已被取消,跳过并处理下一个
|
||||
if (task.cancelled) {
|
||||
tasks.delete(task.id);
|
||||
idle = true;
|
||||
if (tasks.size > 0) {
|
||||
processNextInQueue();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
idle = false;
|
||||
const { webContents, id, downloadDir } = task;
|
||||
|
||||
const sendLog = (msg: string) => {
|
||||
@@ -479,8 +343,6 @@ async function runDownloadPhase(task: InstallTask) {
|
||||
};
|
||||
|
||||
try {
|
||||
if (task.cancelled) throw new Error("下载已取消");
|
||||
|
||||
// 1. Metalink & Aria2c Phase
|
||||
if (task.metalinkUrl) {
|
||||
try {
|
||||
@@ -544,10 +406,8 @@ async function runDownloadPhase(task: InstallTask) {
|
||||
|
||||
sendStatus("downloading");
|
||||
|
||||
// 下载重试逻辑:共10次,指数退避,首次3秒,末次1分钟
|
||||
const timeoutList = [
|
||||
3000, 4500, 6500, 9000, 13000, 18000, 26000, 36000, 50000, 60000,
|
||||
];
|
||||
// 下载重试逻辑:共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;
|
||||
|
||||
@@ -646,50 +506,10 @@ async function runDownloadPhase(task: InstallTask) {
|
||||
}
|
||||
}
|
||||
|
||||
// 下载完成,进入安装队列等待
|
||||
task.phase = "queued-install";
|
||||
} catch (error) {
|
||||
logger.error(`Task ${id} download failed: ${error}`);
|
||||
if (!task.cancelled) {
|
||||
webContents?.send("install-complete", {
|
||||
id,
|
||||
success: false,
|
||||
time: Date.now(),
|
||||
exitCode: -1,
|
||||
message: JSON.stringify({
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
}),
|
||||
});
|
||||
// 进入安装阶段前检查是否已取消
|
||||
if (task.cancelled) {
|
||||
throw new Error("安装已取消");
|
||||
}
|
||||
tasks.delete(id);
|
||||
} finally {
|
||||
activeDownloadCount--;
|
||||
processNextDownload();
|
||||
processNextInstall();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安装阶段:执行安装命令,安装一次只允许一个。
|
||||
*/
|
||||
async function runInstallPhase(task: InstallTask) {
|
||||
const { webContents, id } = task;
|
||||
|
||||
const sendLog = (msg: string) => {
|
||||
webContents?.send("install-log", { id, time: Date.now(), message: msg });
|
||||
};
|
||||
const sendStatus = (status: string) => {
|
||||
webContents?.send("install-status", {
|
||||
id,
|
||||
time: Date.now(),
|
||||
message: status,
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
if (task.cancelled) throw new Error("安装已取消");
|
||||
|
||||
// 2. Install Phase
|
||||
sendStatus("installing");
|
||||
@@ -766,21 +586,8 @@ async function runInstallPhase(task: InstallTask) {
|
||||
stderr: result.stderr,
|
||||
};
|
||||
|
||||
if (success) {
|
||||
logger.info(msgObj);
|
||||
|
||||
// 安装成功后,如果是APM安装的,就调用createApmDesktopShortcut
|
||||
// 这个函数负责处理桌面快捷方式,它自己会读取设置并且决定要不要创建
|
||||
if (task.origin === "apm" && task.pkgname) {
|
||||
try {
|
||||
await createApmDesktopShortcut(task.pkgname, sendLog);
|
||||
} catch (err) {
|
||||
logger.warn(`Failed to create shortcut for ${task.pkgname}: ${err}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.error(msgObj);
|
||||
}
|
||||
if (success) logger.info(msgObj);
|
||||
else logger.error(msgObj);
|
||||
|
||||
webContents?.send("install-complete", {
|
||||
id,
|
||||
@@ -790,25 +597,27 @@ async function runInstallPhase(task: InstallTask) {
|
||||
message: JSON.stringify(msgObj),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Task ${id} install failed: ${error}`);
|
||||
if (!task.cancelled) {
|
||||
webContents?.send("install-complete", {
|
||||
id,
|
||||
success: false,
|
||||
time: Date.now(),
|
||||
exitCode: -1,
|
||||
message: JSON.stringify({
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
}),
|
||||
});
|
||||
}
|
||||
logger.error(`Task ${id} failed: ${error}`);
|
||||
webContents?.send("install-complete", {
|
||||
id,
|
||||
success: false,
|
||||
time: Date.now(),
|
||||
exitCode: -1,
|
||||
message: JSON.stringify({
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
}),
|
||||
});
|
||||
} finally {
|
||||
tasks.delete(id);
|
||||
installIdle = true;
|
||||
processNextInstall();
|
||||
processNextDownload();
|
||||
// 如果已被 cancel handler 清理,跳过重复清理
|
||||
if (!task.cancelled) {
|
||||
tasks.delete(id);
|
||||
idle = true;
|
||||
if (tasks.size > 0) {
|
||||
processNextInQueue();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ 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" });
|
||||
|
||||
@@ -106,10 +105,8 @@ export const downloadPackage = async ({
|
||||
|
||||
onStatus?.("downloading");
|
||||
|
||||
// 下载重试逻辑:共10次,指数退避,首次3秒,末次1分钟
|
||||
const timeoutList = [
|
||||
3000, 4500, 6500, 9000, 13000, 18000, 26000, 36000, 50000, 60000,
|
||||
];
|
||||
// 下载重试逻辑:共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;
|
||||
|
||||
@@ -346,16 +343,21 @@ export const checkApmAvailable = async (): Promise<boolean> => {
|
||||
* 检查提权命令
|
||||
*/
|
||||
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 "";
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn("which", ["/usr/bin/pkexec"]);
|
||||
let stdout = "";
|
||||
child.stdout?.on("data", (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
resolve(stdout.trim());
|
||||
} else {
|
||||
resolve("");
|
||||
}
|
||||
});
|
||||
child.on("error", () => {
|
||||
resolve("");
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,49 +0,0 @@
|
||||
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("");
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -2,8 +2,6 @@ import { spawn } from "node:child_process";
|
||||
|
||||
import { BrowserWindow, ipcMain } from "electron";
|
||||
|
||||
import { SHELL_CALLER_PATH } from "../shared-installer";
|
||||
import { findExecutable, SUPER_USER_COMMAND_CANDIDATES } from "../superuser";
|
||||
import {
|
||||
buildInstalledSourceMap,
|
||||
mergeUpdateSources,
|
||||
@@ -526,115 +524,6 @@ export const registerUpdateCenterIpc = (
|
||||
| "subscribe"
|
||||
>,
|
||||
): void => {
|
||||
ipc.handle(
|
||||
"update-center-run-system-update",
|
||||
async (_event, storeFilter: StoreFilter = "both") => {
|
||||
console.log(
|
||||
`[UpdateCenter] update-center-run-system-update called with storeFilter=${storeFilter}`,
|
||||
);
|
||||
|
||||
const results: { aptss?: string; apm?: string } = {};
|
||||
|
||||
const runCommand = (
|
||||
command: string,
|
||||
args: string[],
|
||||
): Promise<{ code: number; stdout: string; stderr: string }> =>
|
||||
new Promise((resolve) => {
|
||||
const child = spawn(command, args, {
|
||||
shell: false,
|
||||
env: process.env,
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout?.on("data", (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
child.stderr?.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
child.on("error", (err) =>
|
||||
resolve({ code: -1, stdout, stderr: err.message }),
|
||||
);
|
||||
child.on("close", (code) =>
|
||||
resolve({ code: code ?? -1, stdout, stderr }),
|
||||
);
|
||||
});
|
||||
|
||||
const isSourceEnabled = (
|
||||
filter: StoreFilter,
|
||||
source: "spark" | "apm",
|
||||
): boolean => filter === "both" || filter === source;
|
||||
|
||||
// aptss update — 需要提权
|
||||
if (isSourceEnabled(storeFilter, "spark")) {
|
||||
const whichResult = await runCommand("which", ["aptss"]);
|
||||
const aptssAvailable =
|
||||
whichResult.code === 0 && whichResult.stdout.trim().length > 0;
|
||||
if (aptssAvailable) {
|
||||
console.log(
|
||||
"[UpdateCenter] Running: pkexec shell-caller aptss ssupdate",
|
||||
);
|
||||
const superUserCmd = await findExecutable(
|
||||
SUPER_USER_COMMAND_CANDIDATES[0],
|
||||
);
|
||||
if (superUserCmd) {
|
||||
const result = await runCommand(superUserCmd, [
|
||||
SHELL_CALLER_PATH,
|
||||
"aptss",
|
||||
"ssupdate",
|
||||
]);
|
||||
results.aptss =
|
||||
result.code === 0
|
||||
? "ok"
|
||||
: `failed: ${result.stderr.substring(0, 200)}`;
|
||||
console.log("[UpdateCenter] aptss ssupdate result:", results.aptss);
|
||||
} else {
|
||||
results.aptss = "failed: pkexec not found";
|
||||
console.warn(
|
||||
"[UpdateCenter] pkexec not found, skipping aptss update",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
results.aptss = "skipped: aptss not installed";
|
||||
}
|
||||
}
|
||||
|
||||
// apm update — 也需要提权
|
||||
if (isSourceEnabled(storeFilter, "apm")) {
|
||||
const whichResult = await runCommand("which", ["apm"]);
|
||||
const apmAvailable =
|
||||
whichResult.code === 0 && whichResult.stdout.trim().length > 0;
|
||||
if (apmAvailable) {
|
||||
console.log("[UpdateCenter] Running: pkexec shell-caller apm update");
|
||||
const superUserCmd = await findExecutable(
|
||||
SUPER_USER_COMMAND_CANDIDATES[0],
|
||||
);
|
||||
if (superUserCmd) {
|
||||
const result = await runCommand(superUserCmd, [
|
||||
SHELL_CALLER_PATH,
|
||||
"apm",
|
||||
"update",
|
||||
]);
|
||||
results.apm =
|
||||
result.code === 0
|
||||
? "ok"
|
||||
: `failed: ${result.stderr.substring(0, 200)}`;
|
||||
console.log("[UpdateCenter] apm update result:", results.apm);
|
||||
} else {
|
||||
results.apm = "failed: pkexec not found";
|
||||
console.warn(
|
||||
"[UpdateCenter] pkexec not found, skipping apm update",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
results.apm = "skipped: apm not installed";
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
},
|
||||
);
|
||||
|
||||
ipc.handle(
|
||||
"update-center-open",
|
||||
(_event, storeFilter: StoreFilter = "both") => service.open(storeFilter),
|
||||
|
||||
@@ -45,7 +45,7 @@ class ListenersMap {
|
||||
}
|
||||
}
|
||||
|
||||
const protocols = ["spk", "apt"];
|
||||
const protocols = ["spk"];
|
||||
const listeners = new ListenersMap();
|
||||
|
||||
export const deepLink = {
|
||||
@@ -81,23 +81,6 @@ 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}`);
|
||||
|
||||
|
||||
+23
-175
@@ -23,7 +23,6 @@ import {
|
||||
getMainWindowCloseAction,
|
||||
type MainWindowCloseGuardState,
|
||||
} from "./window-close-guard.js";
|
||||
import { registerSubmitterHandlers } from "./backend/submitter.js";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
process.env.APP_ROOT = path.join(__dirname, "../..");
|
||||
@@ -41,23 +40,6 @@ function getAppVersion(): string {
|
||||
}
|
||||
}
|
||||
|
||||
function getSystemInfo(): { distro: string } {
|
||||
try {
|
||||
const raw = fs.readFileSync("/etc/os-release", "utf8");
|
||||
const fields = Object.fromEntries(
|
||||
raw
|
||||
.split("\n")
|
||||
.map((line) => line.match(/^([A-Z_]+)=(.*)$/))
|
||||
.filter((match): match is RegExpMatchArray => match !== null)
|
||||
.map((match) => [match[1], match[2].replace(/^"|"$/g, "")]),
|
||||
);
|
||||
const distro = fields.PRETTY_NAME || fields.NAME || "unknown";
|
||||
return { distro };
|
||||
} catch {
|
||||
return { distro: "unknown" };
|
||||
}
|
||||
}
|
||||
|
||||
// 处理 --version 参数(在单实例检查之前)
|
||||
if (process.argv.includes("--version") || process.argv.includes("-v")) {
|
||||
console.log(getAppVersion());
|
||||
@@ -73,7 +55,6 @@ import "./backend/install-manager.js";
|
||||
import "./handle-url-scheme.js";
|
||||
|
||||
const logger = pino({ name: "index.ts" });
|
||||
const FLARUM_TOKEN_URL = "https://bbs.spark-app.store/api/token";
|
||||
|
||||
// The built directory structure
|
||||
//
|
||||
@@ -113,17 +94,6 @@ const getUserAgent = (): string => {
|
||||
return `Spark-Store/${getAppVersion()}`;
|
||||
};
|
||||
|
||||
let submitterWin: BrowserWindow | null = null;
|
||||
registerSubmitterHandlers(
|
||||
preload,
|
||||
indexHtml,
|
||||
VITE_DEV_SERVER_URL,
|
||||
() => submitterWin,
|
||||
(w) => {
|
||||
submitterWin = w;
|
||||
},
|
||||
);
|
||||
|
||||
logger.info("User Agent: " + getUserAgent());
|
||||
|
||||
/** 根据启动参数 --no-apm / --no-spark 决定只展示的来源 */
|
||||
@@ -147,76 +117,6 @@ ipcMain.handle("get-store-filter", (): "spark" | "apm" | "both" =>
|
||||
);
|
||||
|
||||
ipcMain.handle("get-app-version", (): string => getAppVersion());
|
||||
ipcMain.handle("get-system-info", (): { distro: string } => getSystemInfo());
|
||||
|
||||
ipcMain.handle("request-flarum-token", async (_event, payload: unknown) => {
|
||||
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
||||
throw new Error("登录信息格式不正确,请重新输入。");
|
||||
}
|
||||
|
||||
const credentials = payload as Record<string, unknown>;
|
||||
if (
|
||||
typeof credentials.identification !== "string" ||
|
||||
typeof credentials.password !== "string"
|
||||
) {
|
||||
throw new Error("登录信息格式不正确,请重新输入。");
|
||||
}
|
||||
|
||||
logger.info({ endpoint: FLARUM_TOKEN_URL }, "Requesting Flarum login token");
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(FLARUM_TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
"User-Agent": getUserAgent(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
identification: credentials.identification,
|
||||
password: credentials.password,
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
{ err, endpoint: FLARUM_TOKEN_URL },
|
||||
"Flarum token request failed before response",
|
||||
);
|
||||
throw new Error("无法连接星火论坛,请检查网络后重试。");
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
logger.warn(
|
||||
{ endpoint: FLARUM_TOKEN_URL, status: response.status },
|
||||
"Flarum rejected login token request",
|
||||
);
|
||||
throw new Error("论坛登录失败,请检查账号和密码。");
|
||||
}
|
||||
|
||||
const data = (await response.json()) as Record<string, unknown>;
|
||||
const userId = data.userId ?? data.user_id;
|
||||
if (
|
||||
typeof data.token !== "string" ||
|
||||
userId === undefined ||
|
||||
userId === null
|
||||
) {
|
||||
logger.warn(
|
||||
{
|
||||
endpoint: FLARUM_TOKEN_URL,
|
||||
hasToken: typeof data.token === "string" && data.token.length > 0,
|
||||
hasUserId: userId !== undefined && userId !== null,
|
||||
},
|
||||
"Flarum token response missing required fields",
|
||||
);
|
||||
throw new Error("论坛登录响应异常,请稍后重试。");
|
||||
}
|
||||
|
||||
return {
|
||||
token: data.token,
|
||||
userId: String(userId),
|
||||
};
|
||||
});
|
||||
|
||||
const getMainWindowCloseGuardState = (): MainWindowCloseGuardState => ({
|
||||
installTaskCount: tasks.size,
|
||||
@@ -256,26 +156,11 @@ const requestApplicationExit = (): void => {
|
||||
app.quit();
|
||||
};
|
||||
|
||||
const showAndFocusMainWindow = (): void => {
|
||||
if (!win || win.isDestroyed()) {
|
||||
createWindow();
|
||||
return;
|
||||
}
|
||||
|
||||
if (win.isMinimized()) {
|
||||
win.restore();
|
||||
}
|
||||
win.show();
|
||||
win.setSkipTaskbar(false);
|
||||
win.focus();
|
||||
};
|
||||
|
||||
async function createWindow() {
|
||||
const mainWindow = new BrowserWindow({
|
||||
win = new BrowserWindow({
|
||||
title: "星火应用商店",
|
||||
width: 1366,
|
||||
height: 768,
|
||||
frame: false,
|
||||
autoHideMenuBar: true,
|
||||
icon: path.join(process.env.VITE_PUBLIC, "favicon.ico"),
|
||||
webPreferences: {
|
||||
@@ -288,40 +173,30 @@ async function createWindow() {
|
||||
// contextIsolation: false,
|
||||
},
|
||||
});
|
||||
win = mainWindow;
|
||||
|
||||
if (VITE_DEV_SERVER_URL) {
|
||||
// #298
|
||||
mainWindow.loadURL(VITE_DEV_SERVER_URL);
|
||||
win.loadURL(VITE_DEV_SERVER_URL);
|
||||
// Open devTool if the app is not packaged
|
||||
mainWindow.webContents.openDevTools({ mode: "detach" });
|
||||
win.webContents.openDevTools({ mode: "detach" });
|
||||
} else {
|
||||
mainWindow.loadFile(indexHtml);
|
||||
win.loadFile(indexHtml);
|
||||
}
|
||||
|
||||
// Test actively push message to the Electron-Renderer
|
||||
mainWindow.webContents.on("did-finish-load", () => {
|
||||
mainWindow.webContents.send(
|
||||
"main-process-message",
|
||||
new Date().toLocaleString(),
|
||||
);
|
||||
win.webContents.on("did-finish-load", () => {
|
||||
win?.webContents.send("main-process-message", new Date().toLocaleString());
|
||||
logger.info("Renderer process is ready.");
|
||||
});
|
||||
|
||||
// Make all links open with the browser, not with the application
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
win.webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (url.startsWith("https:")) shell.openExternal(url);
|
||||
return { action: "deny" };
|
||||
});
|
||||
// win.webContents.on('will-navigate', (event, url) => { }) #344
|
||||
|
||||
mainWindow.on("closed", () => {
|
||||
if (win === mainWindow) {
|
||||
win = null;
|
||||
}
|
||||
});
|
||||
|
||||
mainWindow.on("close", (event) => {
|
||||
win.on("close", (event) => {
|
||||
if (allowAppExit) {
|
||||
return;
|
||||
}
|
||||
@@ -344,27 +219,6 @@ ipcMain.on("set-theme-source", (event, theme: "system" | "light" | "dark") => {
|
||||
nativeTheme.themeSource = theme;
|
||||
});
|
||||
|
||||
ipcMain.on("window-control-minimize", () => {
|
||||
win?.minimize();
|
||||
});
|
||||
|
||||
ipcMain.on("window-control-toggle-maximize", () => {
|
||||
if (!win) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (win.isMaximized()) {
|
||||
win.unmaximize();
|
||||
return;
|
||||
}
|
||||
|
||||
win.maximize();
|
||||
});
|
||||
|
||||
ipcMain.on("window-control-close", () => {
|
||||
win?.close();
|
||||
});
|
||||
|
||||
// 配置文件路径
|
||||
const SPARK_CONFIG_DIR = path.join(
|
||||
os.homedir(),
|
||||
@@ -464,22 +318,6 @@ ipcMain.handle("check-for-updates", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
// 启动投稿器窗口
|
||||
// Register custom protocol handlers
|
||||
if (process.defaultApp) {
|
||||
if (process.argv.length >= 2) {
|
||||
app.setAsDefaultProtocolClient("spk", process.execPath, [
|
||||
path.resolve(process.argv[1]),
|
||||
]);
|
||||
app.setAsDefaultProtocolClient("apt", process.execPath, [
|
||||
path.resolve(process.argv[1]),
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
app.setAsDefaultProtocolClient("spk");
|
||||
app.setAsDefaultProtocolClient("apt");
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
// Set User-Agent for client
|
||||
session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => {
|
||||
@@ -500,11 +338,20 @@ app.on("window-all-closed", () => {
|
||||
});
|
||||
|
||||
app.on("second-instance", () => {
|
||||
showAndFocusMainWindow();
|
||||
if (win) {
|
||||
// Focus on the main window if the user tried to open another
|
||||
if (win.isMinimized()) win.restore();
|
||||
win.focus();
|
||||
}
|
||||
});
|
||||
|
||||
app.on("activate", () => {
|
||||
showAndFocusMainWindow();
|
||||
const allWindows = BrowserWindow.getAllWindows();
|
||||
if (allWindows.length) {
|
||||
allWindows[0].focus();
|
||||
} else {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
|
||||
app.on("will-quit", () => {
|
||||
@@ -557,7 +404,7 @@ app.whenReady().then(() => {
|
||||
{
|
||||
label: "显示主界面",
|
||||
click: () => {
|
||||
showAndFocusMainWindow();
|
||||
win.show();
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -572,11 +419,12 @@ app.whenReady().then(() => {
|
||||
// 双击触发
|
||||
tray.on("click", () => {
|
||||
// 双击通知区图标实现应用的显示或隐藏
|
||||
if (win && !win.isDestroyed() && win.isVisible()) {
|
||||
if (win.isVisible()) {
|
||||
win.hide();
|
||||
win.setSkipTaskbar(true);
|
||||
} else {
|
||||
showAndFocusMainWindow();
|
||||
win.show();
|
||||
win.setSkipTaskbar(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
ipcRenderer,
|
||||
contextBridge,
|
||||
webUtils,
|
||||
type IpcRendererEvent,
|
||||
} from "electron";
|
||||
import { ipcRenderer, contextBridge, type IpcRendererEvent } from "electron";
|
||||
|
||||
type StoreFilter = "spark" | "apm" | "both";
|
||||
|
||||
@@ -47,12 +42,6 @@ type IpcRendererFacade = {
|
||||
invoke: typeof ipcRenderer.invoke;
|
||||
};
|
||||
|
||||
type WindowControlBridge = {
|
||||
minimize: () => void;
|
||||
toggleMaximize: () => void;
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
type UpdateCenterStateListener = (snapshot: UpdateCenterSnapshot) => void;
|
||||
type UpdateCenterStartTask = {
|
||||
taskKey: string;
|
||||
@@ -102,16 +91,6 @@ contextBridge.exposeInMainWorld("apm_store", {
|
||||
})(),
|
||||
});
|
||||
|
||||
contextBridge.exposeInMainWorld("electronUtils", {
|
||||
getPathForFile: (file: File): string => webUtils.getPathForFile(file),
|
||||
});
|
||||
|
||||
contextBridge.exposeInMainWorld("windowControls", {
|
||||
minimize: () => ipcRenderer.send("window-control-minimize"),
|
||||
toggleMaximize: () => ipcRenderer.send("window-control-toggle-maximize"),
|
||||
close: () => ipcRenderer.send("window-control-close"),
|
||||
} satisfies WindowControlBridge);
|
||||
|
||||
contextBridge.exposeInMainWorld("updateCenter", {
|
||||
open: (storeFilter: StoreFilter = "both"): Promise<UpdateCenterSnapshot> =>
|
||||
ipcRenderer.invoke("update-center-open", storeFilter),
|
||||
|
||||
@@ -155,10 +155,7 @@ case "$command_type" in
|
||||
echo "操作已取消"
|
||||
exit 0
|
||||
fi
|
||||
elif [[ "$2" == "ssupdate" ]]; then
|
||||
/usr/bin/aptss "${@:2}" -y 2>&1
|
||||
exit_code=$?
|
||||
exit $?
|
||||
|
||||
else
|
||||
# 非 remove/install 命令,拒绝执行
|
||||
echo "拒绝执行 aptss 白名单外的指令"
|
||||
|
||||
+1
-11
@@ -29,16 +29,6 @@ if grep -q "ID=aosc" /etc/os-release; then
|
||||
ARGS="$ARGS --no-spark"
|
||||
fi
|
||||
|
||||
# 检查龙GPU,添加 --disable-gpu
|
||||
ARCH=$(uname -m)
|
||||
if [ "$ARCH" = "loongarch64" ] || [ "$ARCH" = "loong64" ]; then
|
||||
is_loonggpu=$(lspci -s $(basename $(readlink $(grep -l connected /sys/class/drm/card*/*/status 2>/dev/null | head -1 | grep -o 'card[0-9]*' | xargs -I{} echo /sys/class/drm/{}/device))) | grep -qi loongson && echo "Found" || echo "NotFound")
|
||||
if [ "$is_loonggpu" = "Found" ]; then
|
||||
echo "检测到龙GPU"
|
||||
ARGS="$ARGS --disable-gpu"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 注意:已移除原先针对 arm64 + wayland 添加 --disable-gpu 的逻辑,
|
||||
# 现在 arm64 设备无论是否使用 wayland 均不再添加此参数。
|
||||
|
||||
@@ -58,4 +48,4 @@ if [ $exit_code -ne 0 ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
exit $exit_code
|
||||
exit $exit_code
|
||||
|
||||
-158
@@ -1,158 +0,0 @@
|
||||
{
|
||||
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
+20
-20
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "spark-store",
|
||||
"version": "5.2.1-0",
|
||||
"version": "5.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "spark-store",
|
||||
"version": "5.2.1-0",
|
||||
"version": "5.0.0",
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
@@ -28,7 +28,7 @@
|
||||
"@vue/test-utils": "^2.4.3",
|
||||
"conventional-changelog": "^7.1.1",
|
||||
"conventional-changelog-angular": "^8.1.0",
|
||||
"electron": "^37.2.5",
|
||||
"electron": "^39.2.7",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
@@ -45,7 +45,7 @@
|
||||
"vite-plugin-electron-renderer": "^0.14.5",
|
||||
"vitest": "^4.1.4",
|
||||
"vue": "^3.4.21",
|
||||
"vue-tsc": "^3.3.5"
|
||||
"vue-tsc": "^3.2.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
@@ -3348,18 +3348,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/language-core": {
|
||||
"version": "3.3.5",
|
||||
"resolved": "https://registry.npmmirror.com/@vue/language-core/-/language-core-3.3.5.tgz",
|
||||
"integrity": "sha512-UkKu5nhX89fg4VhlG/FOeI10G3cj/7radKT/cy9BT4Q9qJmJlSTAc/dP63Xqs29aypN4f39xUV6PsLNk/dcD6g==",
|
||||
"version": "3.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.2.5.tgz",
|
||||
"integrity": "sha512-d3OIxN/+KRedeM5wQ6H6NIpwS3P5gC9nmyaHgBk+rO6dIsjY+tOh4UlPpiZbAh3YtLdCGEX4M16RmsBqPmJV+g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@volar/language-core": "2.4.28",
|
||||
"@vue/compiler-dom": "^3.5.0",
|
||||
"@vue/shared": "^3.5.0",
|
||||
"alien-signals": "^3.2.0",
|
||||
"alien-signals": "^3.0.0",
|
||||
"muggle-string": "^0.4.1",
|
||||
"path-browserify": "^1.0.1",
|
||||
"picomatch": "^4.0.4"
|
||||
"picomatch": "^4.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/reactivity": {
|
||||
@@ -3494,9 +3494,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/alien-signals": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmmirror.com/alien-signals/-/alien-signals-3.2.1.tgz",
|
||||
"integrity": "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==",
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.1.2.tgz",
|
||||
"integrity": "sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/ansi-colors": {
|
||||
@@ -4870,9 +4870,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron": {
|
||||
"version": "37.2.5",
|
||||
"resolved": "https://registry.npmjs.org/electron/-/electron-37.2.5.tgz",
|
||||
"integrity": "sha512-719ZqEp43rj6xDJMICm4CIXl8keFFgvVNO9Ix6OtjNjrh9HtYlP/1WiYeRohnXj06aLyGx5NCzrHbG7j3BxO9w==",
|
||||
"version": "39.2.7",
|
||||
"resolved": "https://registry.npmjs.org/electron/-/electron-39.2.7.tgz",
|
||||
"integrity": "sha512-KU0uFS6LSTh4aOIC3miolcbizOFP7N1M46VTYVfqIgFiuA2ilfNaOHLDS9tCMvwwHRowAsvqBrh9NgMXcTOHCQ==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
@@ -7636,7 +7636,7 @@
|
||||
},
|
||||
"node_modules/muggle-string": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz",
|
||||
"resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
|
||||
"integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==",
|
||||
"dev": true
|
||||
},
|
||||
@@ -10088,13 +10088,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vue-tsc": {
|
||||
"version": "3.3.5",
|
||||
"resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-3.3.5.tgz",
|
||||
"integrity": "sha512-Rzh/G2MmNlMSAMTiQEjDrsb4dgB/jbtEM47rVN2NtidF1dfb/q4w4QvpQBtW5+y3y5H27Hjh7deVwk+YB02fNg==",
|
||||
"version": "3.2.5",
|
||||
"resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.2.5.tgz",
|
||||
"integrity": "sha512-/htfTCMluQ+P2FISGAooul8kO4JMheOTCbCy4M6dYnYYjqLe3BExZudAua6MSIKSFYQtFOYAll7XobYwcpokGA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@volar/typescript": "2.4.28",
|
||||
"@vue/language-core": "3.3.5"
|
||||
"@vue/language-core": "3.2.5"
|
||||
},
|
||||
"bin": {
|
||||
"vue-tsc": "bin/vue-tsc.js"
|
||||
|
||||
+5
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "spark-store",
|
||||
"version": "5.2.1-0",
|
||||
"version": "5.1.1",
|
||||
"main": "dist-electron/main/index.js",
|
||||
"description": "Client for Spark App Store",
|
||||
"author": "elysia-best <elysia-best@simplelinux.cn.eu.org>",
|
||||
@@ -26,11 +26,11 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --mode debug | pino-pretty",
|
||||
"build": "vue-tsc --noEmit && vite build --mode production && electron-builder --config electron-builder.yml --linux dir",
|
||||
"build": "vue-tsc --noEmit && vite build --mode production && electron-builder --config electron-builder.yml",
|
||||
"build:vite": "vue-tsc --noEmit && vite build --mode production",
|
||||
"build:rpm": "vue-tsc --noEmit && vite build --mode production && electron-builder --config electron-builder.yml --linux rpm",
|
||||
"build:deb": "vue-tsc --noEmit && vite build --mode production && electron-builder --config electron-builder.yml --linux deb",
|
||||
"build:loong64": "vue-tsc --noEmit && vite build --mode production && env ELECTRON_MIRROR=https://github.com/darkyzhou/electron-loong64/releases/download/ electron_use_remote_checksums=1 electron-builder --config electron-builder.yml --loong64 --linux dir",
|
||||
"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",
|
||||
@@ -57,7 +57,7 @@
|
||||
"@vue/test-utils": "^2.4.3",
|
||||
"conventional-changelog": "^7.1.1",
|
||||
"conventional-changelog-angular": "^8.1.0",
|
||||
"electron": "^37.2.5",
|
||||
"electron": "^39.2.7",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
@@ -74,7 +74,7 @@
|
||||
"vite-plugin-electron-renderer": "^0.14.5",
|
||||
"vitest": "^4.1.4",
|
||||
"vue": "^3.4.21",
|
||||
"vue-tsc": "^3.3.5"
|
||||
"vue-tsc": "^3.2.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
Package: *
|
||||
Pin: origin *.deepinos.org.cn
|
||||
Pin-Priority: 400
|
||||
@@ -0,0 +1 @@
|
||||
deb [by-hash=force] https://d.store.deepinos.org.cn /
|
||||
@@ -0,0 +1,11 @@
|
||||
[Unit]
|
||||
Description=Timer for Spark Update Notifier
|
||||
|
||||
[Timer]
|
||||
# 开机后第一次执行
|
||||
OnBootSec=1min
|
||||
# 每天执行一次
|
||||
OnUnitActiveSec=1d
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
#!/bin/sh
|
||||
|
||||
echo "From:sparkstorefeedback@163.com
|
||||
To:sparkstorefeedback@163.com
|
||||
Subject: spark-store_3.0.2: $(lsb_release -a | grep "Description" | sed -e "s#\t#@#" | cut -d "@" -f 2)
|
||||
|
||||
$(uname -a)" | tee /tmp/spark-store-install/feedback.txt > /dev/null
|
||||
|
||||
curl -s --url "smtp://smtp.163.com" --mail-from "${MAIL_FEEDBACK}" --mail-rcpt "${MAIL_FEEDBACK}" --upload-file /tmp/spark-store-install/feedback.txt --user "${MAIL_FEEDBACK}:${M}AIL_AUTH"
|
||||
@@ -3,7 +3,7 @@ Description=Spark Store update notifier
|
||||
After=apt-daily.service network.target network-online.target systemd-networkd.service NetworkManager.service connman.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
Type=simple
|
||||
RemainAfterExit=yes
|
||||
ExecStart=/opt/durapps/spark-store/bin/update-upgrade/ss-update-notifier.sh
|
||||
Restart=on-failure
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
[Unit]
|
||||
Description=Spark Store update notifier
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-*-* 6,18:00
|
||||
RandomizedDelaySec=12h
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -10,4 +10,4 @@ Keywords=appstore;
|
||||
Terminal=false
|
||||
StartupNotify=true
|
||||
StartupWMClass=spark-store
|
||||
MimeType=x-scheme-handler/spk;x-scheme-handler/apt
|
||||
MimeType=x-scheme-handler/spk
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
#!/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)."
|
||||
@@ -0,0 +1,62 @@
|
||||
build
|
||||
.vscode
|
||||
.cache
|
||||
CMakeLists.txt.user
|
||||
CMakeLists.txt.user.*
|
||||
obj-x86_64-linux-gnu
|
||||
# C++ objects and libs
|
||||
*.slo
|
||||
*.lo
|
||||
*.o
|
||||
*.a
|
||||
*.la
|
||||
*.lai
|
||||
*.so
|
||||
*.dll
|
||||
*.dylib
|
||||
|
||||
# Qt-es
|
||||
object_script.*.Release
|
||||
object_script.*.Debug
|
||||
*_plugin_import.cpp
|
||||
/.qmake.cache
|
||||
/.qmake.stash
|
||||
*.pro.user
|
||||
*.pro.user.*
|
||||
*.qbs.user
|
||||
*.qbs.user.*
|
||||
*.moc
|
||||
moc_*.cpp
|
||||
moc_*.h
|
||||
qrc_*.cpp
|
||||
ui_*.h
|
||||
*.qmlc
|
||||
*.jsc
|
||||
Makefile*
|
||||
*build-*
|
||||
|
||||
# Qt unit tests
|
||||
target_wrapper.*
|
||||
|
||||
# Qt qm files
|
||||
translations/*.qm
|
||||
|
||||
# QtCreator
|
||||
*.autosave
|
||||
|
||||
# QtCreator Qml
|
||||
*.qmlproject.user
|
||||
*.qmlproject.user.*
|
||||
|
||||
# QtCreator CMake
|
||||
CMakeLists.txt.user*
|
||||
build
|
||||
|
||||
# Debian dpkg-buildpackage
|
||||
debian/*.debhelper*
|
||||
debian/files
|
||||
debian/*.substvars
|
||||
debian/spark-update-tool
|
||||
|
||||
.vscode/*
|
||||
src/spark-update-tool
|
||||
@@ -0,0 +1,99 @@
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
|
||||
project(spark-update-tool VERSION 0.1 LANGUAGES CXX)
|
||||
|
||||
set(CMAKE_AUTOUIC ON)
|
||||
set(CMAKE_AUTOMOC ON)
|
||||
set(CMAKE_AUTORCC ON)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
|
||||
find_package(QT NAMES Qt6 Qt5 REQUIRED COMPONENTS Widgets Network Concurrent)
|
||||
find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Widgets Network Concurrent)
|
||||
|
||||
set(PROJECT_SOURCES
|
||||
src/main.cpp
|
||||
src/mainwindow.cpp
|
||||
src/mainwindow.h
|
||||
src/mainwindow.ui
|
||||
src/aptssupdater.h
|
||||
src/aptssupdater.cpp
|
||||
src/icons.qrc
|
||||
src/appdelegate.h
|
||||
src/appdelegate.cpp
|
||||
src/applistmodel.h
|
||||
src/applistmodel.cpp
|
||||
src/downloadmanager.h
|
||||
src/downloadmanager.cpp
|
||||
src/ignoreconfig.h
|
||||
src/ignoreconfig.cpp
|
||||
)
|
||||
|
||||
if(${QT_VERSION_MAJOR} GREATER_EQUAL 6)
|
||||
qt_add_executable(spark-update-tool
|
||||
MANUAL_FINALIZATION
|
||||
${PROJECT_SOURCES}
|
||||
)
|
||||
# Define target properties for Android with Qt 6 as:
|
||||
# set_property(TARGET spark-update-tool APPEND PROPERTY QT_ANDROID_PACKAGE_SOURCE_DIR
|
||||
# ${CMAKE_CURRENT_SOURCE_DIR}/android)
|
||||
# For more information, see https://doc.qt.io/qt-6/qt-add-executable.html#target-creation
|
||||
else()
|
||||
if(ANDROID)
|
||||
add_library(spark-update-tool SHARED
|
||||
${PROJECT_SOURCES}
|
||||
)
|
||||
# Define properties for Android with Qt 5 after find_package() calls as:
|
||||
# set(ANDROID_PACKAGE_SOURCE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/android")
|
||||
else()
|
||||
add_executable(spark-update-tool
|
||||
${PROJECT_SOURCES}
|
||||
)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
target_link_libraries(spark-update-tool PRIVATE Qt${QT_VERSION_MAJOR}::Widgets Qt${QT_VERSION_MAJOR}::Network Qt${QT_VERSION_MAJOR}::Concurrent)
|
||||
|
||||
# Qt for iOS sets MACOSX_BUNDLE_GUI_IDENTIFIER automatically since Qt 6.1.
|
||||
# If you are developing for iOS or macOS you should consider setting an
|
||||
# explicit, fixed bundle identifier manually though.
|
||||
if(${QT_VERSION} VERSION_LESS 6.1.0)
|
||||
set(BUNDLE_ID_OPTION MACOSX_BUNDLE_GUI_IDENTIFIER com.example.spark-update-tool)
|
||||
endif()
|
||||
set_target_properties(spark-update-tool PROPERTIES
|
||||
${BUNDLE_ID_OPTION}
|
||||
MACOSX_BUNDLE_BUNDLE_VERSION ${PROJECT_VERSION}
|
||||
MACOSX_BUNDLE_SHORT_VERSION_STRING ${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}
|
||||
MACOSX_BUNDLE TRUE
|
||||
WIN32_EXECUTABLE TRUE
|
||||
)
|
||||
|
||||
# 安装配置
|
||||
# 对于Linux,安装到/usr/bin目录
|
||||
if(UNIX AND NOT APPLE)
|
||||
# 直接指定安装路径为 /usr/bin
|
||||
install(TARGETS spark-update-tool
|
||||
RUNTIME DESTINATION /usr/bin
|
||||
)
|
||||
|
||||
# 可选:安装桌面文件和应用图标
|
||||
# install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/spark-update-tool.desktop
|
||||
# DESTINATION /usr/share/applications
|
||||
# )
|
||||
# install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/icons/spark-update-tool.png
|
||||
# DESTINATION /usr/share/icons/hicolor/256x256/apps
|
||||
# )
|
||||
else()
|
||||
# 对于其他系统,使用GNU标准安装路径
|
||||
include(GNUInstallDirs)
|
||||
install(TARGETS spark-update-tool
|
||||
BUNDLE DESTINATION .
|
||||
LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
|
||||
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
|
||||
)
|
||||
endif()
|
||||
|
||||
if(QT_VERSION_MAJOR EQUAL 6)
|
||||
qt_finalize_executable(spark-update-tool)
|
||||
endif()
|
||||
@@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
@@ -0,0 +1,15 @@
|
||||
### Spark Update Tool
|
||||
#### Introduction
|
||||
|
||||
Welcome to Spark Software Updater. Use this tool to update applications on your Linux system.
|
||||
This version is specifically designed for Linux distributions with Qt6 support.
|
||||
Please run under root privileges (recommended: use `sudo`).
|
||||
#### Currently Supported Linux Distributions
|
||||
- [x] GXDE OS
|
||||
- [x] Ubuntu
|
||||
- [x] deepin
|
||||
- [ ] Kylin
|
||||
|
||||
|
||||
#### Contact & Feedback
|
||||
momen@momen.world
|
||||
@@ -0,0 +1,25 @@
|
||||
### 星火软件更新器
|
||||
#### 简介
|
||||
|
||||
欢迎使用星火软件更新器,您可以使用此更新器更新位于您 Linux 计算机的程序。
|
||||
此版本专为有qt6的Linux发行版所使用。
|
||||
请在root环境下运行。
|
||||
#### 当前支持的 Linux 发行版
|
||||
- [x] GXDE OS
|
||||
- [x] Ubuntu
|
||||
- [x] deepin
|
||||
- [ ] Kylin
|
||||
|
||||
#### 功能清单
|
||||
|
||||
| 功能模块 | 描述 |
|
||||
|------------------|--------------------------------------|
|
||||
| 应用名识别 | 基于 `ss-do-upgrade.sh` 部分代码实现 |
|
||||
| 应用包大小识别 | 通过 dpkg 获取包大小信息 |
|
||||
| 获取应用图标 | 利用 QDesktopServices 实现 |
|
||||
| 支持 ACE 兼容环境| |
|
||||
| 多线程下载 | 基于 aptss 方案 |
|
||||
|
||||
如您已安装星火应用商店,则会附带本程序。
|
||||
#### 联系与反馈
|
||||
momen@momen.world
|
||||
@@ -0,0 +1,27 @@
|
||||
spark-update-tool (1.0.4) unstable; urgency=low
|
||||
|
||||
* 修复点击更新全部按钮后,会更新被忽略应用的问题。
|
||||
|
||||
spark-update-tool (1.0.3) unstable; urgency=low
|
||||
|
||||
* 修复默认图标加载失败的问题
|
||||
* 修复更新器在安装阶段强制关闭窗口后再次更新无法安装软件包的问题。
|
||||
|
||||
-- momen <vmomenv@gmail.com> Fri, 17 Oct 2025 00:00:00 +0000
|
||||
spark-update-tool (1.0.2) unstable; urgency=low
|
||||
|
||||
* 添加复选框,选择多个包更新
|
||||
* 修复缩放问题
|
||||
* 添加忽略应用功能
|
||||
|
||||
-- momen <vmomenv@gmail.com> Mon, 29 Sep 2025 00:00:00 +0000
|
||||
spark-update-tool (1.0.1) unstable; urgency=low
|
||||
|
||||
* 修复窗口调整大小时的错误
|
||||
|
||||
-- momen <vmomenv@gmail.com> Wed, 18 Jun 2025 00:00:00 +0000
|
||||
spark-update-tool (1.0.0) unstable; urgency=low
|
||||
|
||||
* Initial release.
|
||||
|
||||
-- momen <vmomenv@gmail.com> Wed, 18 Jun 2025 00:00:00 +0000
|
||||
@@ -0,0 +1 @@
|
||||
13
|
||||
@@ -0,0 +1,13 @@
|
||||
Source: spark-update-tool
|
||||
Section: utils
|
||||
Priority: optional
|
||||
Maintainer: momen <vmomenv@gmail.com>
|
||||
Build-Depends: debhelper (>= 9)
|
||||
Standards-Version: 3.9.6
|
||||
Homepage: https://gitee.com/spark-store-project/Spark-Update-Tool
|
||||
|
||||
Package: spark-update-tool
|
||||
Architecture: any
|
||||
Depends: ${shlibs:Depends}, ${misc:Depends}
|
||||
Description: Spark Update Tool
|
||||
星火应用商店更新组件。This package provides the Spark Update Tool. It includes features for checking for updates, downloading, and applying them seamlessly.
|
||||
@@ -0,0 +1,7 @@
|
||||
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
|
||||
Upstream-Name: spark-update-tool
|
||||
Source: https://gitee.com/spark-store-project/Spark-Update-Tool
|
||||
|
||||
Files: *
|
||||
Copyright: 2025, momen <vmomenv@gmail.com>
|
||||
License: GPL-3.0+
|
||||
@@ -0,0 +1,3 @@
|
||||
build/spark-update-tool /usr/bin/
|
||||
debian/spark-update-tool.desktop /usr/share/applications
|
||||
resources/128*128/spark-update-tool.png /usr/share/icons/hicolor/128x128/apps
|
||||
@@ -0,0 +1,11 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
case "$1" in
|
||||
purge)
|
||||
|
||||
rm -rf /usr/share/spark-update-tool
|
||||
;;
|
||||
esac
|
||||
|
||||
exit 0
|
||||
Executable
+27
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/make -f
|
||||
|
||||
# 声明兼容性级别
|
||||
export DH_VERBOSE=1
|
||||
|
||||
%:
|
||||
dh $@ --buildsystem=cmake
|
||||
|
||||
# 确保使用CMake进行配置
|
||||
override_dh_auto_configure:
|
||||
dh_auto_configure -- -DCMAKE_INSTALL_PREFIX=/usr
|
||||
|
||||
# 确保使用CMake进行构建
|
||||
override_dh_auto_build:
|
||||
dh_auto_build
|
||||
|
||||
# 确保使用CMake进行安装
|
||||
override_dh_auto_install:
|
||||
dh_auto_install
|
||||
|
||||
# 确保使用CMake进行清理
|
||||
override_dh_auto_clean:
|
||||
dh_auto_clean
|
||||
|
||||
# 确保使用CMake进行依赖解析
|
||||
override_dh_shlibdeps:
|
||||
dh_shlibdeps --dpkg-shlibdeps-params=--ignore-missing-info
|
||||
@@ -0,0 +1 @@
|
||||
3.0 (native)
|
||||
@@ -0,0 +1,9 @@
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Name=Spark Update Tool
|
||||
Comment=A Qt-based application for managing and updating software
|
||||
Exec=spark-update-tool
|
||||
Icon=spark-update-tool
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Categories=Utility;
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 8.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 98 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 86 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="12" height="16" viewBox="0 0 12 16"><defs><clipPath id="master_svg0_1_074"><rect x="0.75" y="2" width="10.5" height="12" rx="0"/></clipPath></defs><g><rect x="0" y="0" width="12" height="16" rx="0" fill="#000000" fill-opacity="0" style="mix-blend-mode:passthrough"/><g clip-path="url(#master_svg0_1_074)"><g transform="matrix(1,0,0,-1,0,24.015625)"><g><path d="M4.71094,12.2187505Q4.94531,12.0078125,5.25,12.0078125Q5.55469,12.0078125,5.78906,12.2187505L10.2891,16.7187525Q10.5,16.9531225,10.5,17.2578125Q10.5,17.5625025,10.2891,17.7968725Q10.0547,18.0078125,9.75,18.0078125Q9.44531,18.0078125,9.21094,17.7968725L5.25,13.8125025L1.28906,17.7968725Q1.05469,18.0078125,0.75,18.0078125Q0.445312,18.0078125,0.210938,17.7968725Q0,17.5625025,0,17.2578125Q0,16.9531225,0.210938,16.7187525L4.71094,12.2187505Z" fill="#4B5563" fill-opacity="1" style="mix-blend-mode:passthrough"/></g></g></g></g></svg>
|
||||
|
After Width: | Height: | Size: 1009 B |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 14 KiB |
Executable
BIN
Binary file not shown.
@@ -0,0 +1,54 @@
|
||||
QT += core gui widgets network concurrent
|
||||
TARGET = spark-update-tool
|
||||
TEMPLATE = app
|
||||
|
||||
# Set C++ standard to C++17
|
||||
CONFIG += c++17
|
||||
|
||||
# Enable auto features (uic, moc, rcc)
|
||||
CONFIG += qt warn_on release
|
||||
|
||||
# Version info
|
||||
VERSION = 0.1.0
|
||||
DEFINES += APP_VERSION=\\\"$$VERSION\\\"
|
||||
|
||||
# Source files
|
||||
SOURCES += \
|
||||
src/main.cpp \
|
||||
src/mainwindow.cpp \
|
||||
src/aptssupdater.cpp \
|
||||
src/appdelegate.cpp \
|
||||
src/applistmodel.cpp \
|
||||
src/downloadmanager.cpp \
|
||||
src/ignoreconfig.cpp
|
||||
|
||||
HEADERS += \
|
||||
src/mainwindow.h \
|
||||
src/aptssupdater.h \
|
||||
src/appdelegate.h \
|
||||
src/applistmodel.h \
|
||||
src/downloadmanager.h \
|
||||
src/ignoreconfig.h
|
||||
|
||||
FORMS += \
|
||||
src/mainwindow.ui
|
||||
|
||||
RESOURCES += \
|
||||
src/icons.qrc
|
||||
|
||||
# Linux-specific settings
|
||||
unix:!macx {
|
||||
# 安装到 /usr/bin 目录
|
||||
target.path = /usr/bin
|
||||
INSTALLS += target
|
||||
|
||||
# 如果需要安装其他文件(如桌面文件、图标等),可以添加
|
||||
# desktop.path = /usr/share/applications
|
||||
# desktop.files = spark-update-tool.desktop
|
||||
# INSTALLS += desktop
|
||||
|
||||
# Additional Linux specific configurations if needed
|
||||
QMAKE_CXXFLAGS += -Wall -Wextra
|
||||
}
|
||||
|
||||
# Remove Windows and macOS specific sections since we're focusing on Linux
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,75 @@
|
||||
#pragma once
|
||||
|
||||
#include <QStyledItemDelegate>
|
||||
#include <QHash>
|
||||
#include <QQueue>
|
||||
#include <QProcess>
|
||||
#include <QElapsedTimer>
|
||||
#include <QTimer>
|
||||
#include <QSet>
|
||||
|
||||
#include "downloadmanager.h"
|
||||
|
||||
struct DownloadInfo {
|
||||
int progress = 0;
|
||||
bool isDownloading = false;
|
||||
bool isInstalled = false;
|
||||
bool isInstalling = false;
|
||||
};
|
||||
|
||||
class AppDelegate : public QStyledItemDelegate {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit AppDelegate(QObject *parent = nullptr);
|
||||
~AppDelegate();
|
||||
|
||||
void setModel(QAbstractItemModel *model);
|
||||
|
||||
void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override;
|
||||
QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override;
|
||||
bool editorEvent(QEvent *event, QAbstractItemModel *model,
|
||||
const QStyleOptionViewItem &option, const QModelIndex &index) override;
|
||||
void startDownloadForAll();
|
||||
void startDownloadForSelected();
|
||||
|
||||
// 复选框相关方法
|
||||
void setSelectedPackages(const QSet<QString> &selected);
|
||||
QSet<QString> getSelectedPackages() const;
|
||||
void clearSelection();
|
||||
|
||||
// 获取下载状态信息
|
||||
const QHash<QString, DownloadInfo>& getDownloads() const;
|
||||
|
||||
|
||||
signals:
|
||||
void updateDisplay(const QString &packageName);
|
||||
void updateFinished(bool success); //传递是否完成更新
|
||||
void ignoreApp(const QString &packageName, const QString &version); // 新增:忽略应用信号
|
||||
void unignoreApp(const QString &packageName, const QString &version); // 新增:取消忽略应用信号
|
||||
|
||||
private slots:
|
||||
void updateSpinner(); // 新增槽函数
|
||||
|
||||
private:
|
||||
DownloadManager *m_downloadManager;
|
||||
QHash<QString, DownloadInfo> m_downloads;
|
||||
QAbstractItemModel *m_model = nullptr;
|
||||
|
||||
// 复选框相关成员变量
|
||||
QSet<QString> m_selectedPackages;
|
||||
|
||||
// 迁移包集合(用户确认要迁移的包)
|
||||
QSet<QString> m_migrationPackages;
|
||||
|
||||
QQueue<QString> m_installQueue;
|
||||
bool m_isInstalling = false;
|
||||
QProcess *m_installProcess = nullptr;
|
||||
QString m_installingPackage;
|
||||
QElapsedTimer m_spinnerTimer;
|
||||
|
||||
QTimer m_spinnerUpdateTimer; // 新增定时器
|
||||
int m_spinnerAngle = 0; // 新增角度变量
|
||||
|
||||
void enqueueInstall(const QString &packageName);
|
||||
void startNextInstall();
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
#include "applistmodel.h"
|
||||
|
||||
AppListModel::AppListModel(QObject *parent) : QAbstractListModel(parent) {}
|
||||
|
||||
int AppListModel::rowCount(const QModelIndex &parent) const
|
||||
{
|
||||
if (parent.isValid())
|
||||
return 0;
|
||||
return m_data.size();
|
||||
}
|
||||
|
||||
QVariant AppListModel::data(const QModelIndex &index, int role) const
|
||||
{
|
||||
if (!index.isValid() || index.row() >= m_data.size())
|
||||
return QVariant();
|
||||
|
||||
const QVariantMap &map = m_data.at(index.row()); // 直接访问 QVariantMap
|
||||
switch (role) {
|
||||
case Qt::DisplayRole:
|
||||
return map.value("name");
|
||||
case Qt::UserRole + 1: // 包名
|
||||
return map.value("package");
|
||||
case Qt::UserRole + 2: // 当前版本
|
||||
return map.value("current_version");
|
||||
case Qt::UserRole + 3: // 新版本
|
||||
return map.value("new_version");
|
||||
case Qt::UserRole + 4: // 图标路径
|
||||
return map.value("icon");
|
||||
case Qt::UserRole + 5: // 文件大小
|
||||
return map.value("size");
|
||||
case Qt::UserRole + 6: // 描述
|
||||
return map.value("description");
|
||||
case Qt::UserRole + 7: // 下载 URL
|
||||
return map.value("download_url"); // 返回下载 URL
|
||||
case Qt::UserRole + 8: // 忽略状态
|
||||
return map.value("ignored");
|
||||
case Qt::UserRole + 9: // 包来源
|
||||
return map.value("source");
|
||||
case Qt::UserRole + 10: // 是否为迁移项
|
||||
return map.value("is_migration");
|
||||
case Qt::UserRole + 11: // 迁移源
|
||||
return map.value("migration_source");
|
||||
case Qt::UserRole + 12: // 迁移目标
|
||||
return map.value("migration_target");
|
||||
default:
|
||||
return QVariant();
|
||||
}
|
||||
}
|
||||
|
||||
void AppListModel::setUpdateData(const QJsonArray &updateInfo)
|
||||
{
|
||||
beginResetModel();
|
||||
m_data.clear(); // 清空 QList<QVariantMap>
|
||||
|
||||
for (const auto &item : updateInfo) {
|
||||
QJsonObject obj = item.toObject();
|
||||
QVariantMap map;
|
||||
map["package"] = obj["package"].toString();
|
||||
map["name"] = obj["name"].toString();
|
||||
map["current_version"] = obj["current_version"].toString();
|
||||
map["new_version"] = obj["new_version"].toString();
|
||||
map["icon"] = obj["icon"].toString();
|
||||
map["size"] = obj["size"].toString();
|
||||
map["download_url"] = obj["download_url"].toString(); // 确保设置下载 URL
|
||||
map["ignored"] = obj["ignored"].toBool(); // 设置忽略状态
|
||||
map["source"] = obj["source"].toString(); // 设置包来源
|
||||
map["is_migration"] = obj["is_migration"].toBool(); // 设置是否为迁移项
|
||||
map["migration_source"] = obj["migration_source"].toString(); // 设置迁移源
|
||||
map["migration_target"] = obj["migration_target"].toString(); // 设置迁移目标
|
||||
m_data.append(map); // 添加到 QList<QVariantMap>
|
||||
|
||||
qDebug() << "设置到模型的包名:" << map["package"].toString() << "忽略状态:" << map["ignored"].toBool() << "来源:" << map["source"].toString();
|
||||
qDebug() << "设置到模型的下载 URL:" << map["download_url"].toString(); // 检查设置的数据
|
||||
}
|
||||
|
||||
endResetModel();
|
||||
}
|
||||
|
||||
bool AppListModel::isAppIgnored(const QModelIndex &index) const
|
||||
{
|
||||
if (!index.isValid() || index.row() >= m_data.size())
|
||||
return false;
|
||||
|
||||
const QVariantMap &map = m_data.at(index.row());
|
||||
return map.value("ignored").toBool();
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
#ifndef APPLISTMODEL_H
|
||||
#define APPLISTMODEL_H
|
||||
|
||||
#include <QAbstractListModel>
|
||||
#include <QJsonArray>
|
||||
// 添加 QJsonObject 头文件
|
||||
#include <QJsonObject>
|
||||
#include <QDebug>
|
||||
class AppListModel : public QAbstractListModel
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit AppListModel(QObject *parent = nullptr);
|
||||
|
||||
// 重写方法
|
||||
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
|
||||
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
|
||||
|
||||
// 设置更新数据
|
||||
void setUpdateData(const QJsonArray &data);
|
||||
|
||||
// 获取忽略状态
|
||||
bool isAppIgnored(const QModelIndex &index) const;
|
||||
|
||||
private:
|
||||
QList<QVariantMap> m_data; // 修改类型为 QList<QVariantMap>
|
||||
};
|
||||
|
||||
#endif // APPLISTMODEL_H
|
||||
@@ -0,0 +1,709 @@
|
||||
#include "aptssupdater.h"
|
||||
#include <QProcess>
|
||||
#include <QTextStream>
|
||||
#include <QRegularExpression>
|
||||
#include <QFile>
|
||||
#include <QDir>
|
||||
#include <qdebug.h>
|
||||
|
||||
aptssUpdater::aptssUpdater(QWidget *parent)
|
||||
: QWidget(parent)
|
||||
{
|
||||
packageName = getUpdateablePackages();
|
||||
apmPackageName = getApmUpdateablePackages();
|
||||
}
|
||||
|
||||
QStringList aptssUpdater::getUpdateablePackages()
|
||||
{
|
||||
QStringList packageDetails;
|
||||
|
||||
// 检查aptss命令是否存在
|
||||
QProcess checkProcess;
|
||||
checkProcess.start("which", QStringList() << "aptss");
|
||||
if (!checkProcess.waitForFinished(5000) || checkProcess.exitCode() != 0) {
|
||||
qDebug() << "aptss命令不存在,跳过Spark更新检查";
|
||||
return packageDetails;
|
||||
}
|
||||
|
||||
QProcess process;
|
||||
QString command = R"(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')";
|
||||
|
||||
process.start("bash", QStringList() << "-c" << command);
|
||||
if (!process.waitForFinished(30000)) { // 30秒超时
|
||||
qWarning() << "Process failed to finish within 30 seconds.";
|
||||
process.kill();
|
||||
return packageDetails;
|
||||
}
|
||||
|
||||
QString output = process.readAllStandardOutput();
|
||||
#if QT_VERSION >= QT_VERSION_CHECK(5, 14, 0)
|
||||
QStringList lines = output.split('\n', Qt::SkipEmptyParts);
|
||||
#else
|
||||
QStringList lines = output.split('\n', QString::SkipEmptyParts);
|
||||
#endif
|
||||
|
||||
|
||||
// 创建临时文件
|
||||
QTemporaryFile tempFile;
|
||||
tempFile.setAutoRemove(false);
|
||||
if (tempFile.open()) {
|
||||
QTextStream stream(&tempFile);
|
||||
|
||||
for (const QString &line : lines) {
|
||||
QRegularExpression regex(R"(([\w\-\+\.]+)/\S+\s+([^\s]+)\s+\S+\s+\[upgradable from: ([^\]]+)\])");
|
||||
QRegularExpressionMatch match = regex.match(line);
|
||||
if (match.hasMatch()) {
|
||||
QString name = match.captured(1);
|
||||
QString newVersion = match.captured(2);
|
||||
QString oldVersion = match.captured(3);
|
||||
|
||||
// 检查版本是否相同,相同则跳过
|
||||
if (newVersion == oldVersion) {
|
||||
qDebug() << "跳过版本相同的包:" << name << "(" << oldVersion << "→" << newVersion << ")";
|
||||
continue;
|
||||
}
|
||||
|
||||
// 写入内存列表
|
||||
packageDetails << QString("%1: %2 → %3").arg(name, oldVersion, newVersion);
|
||||
|
||||
// 写入临时文件(原始数据)
|
||||
stream << name << "|" << oldVersion << "|" << newVersion << "\n";
|
||||
}
|
||||
}
|
||||
tempFile.close();
|
||||
m_tempFilePath = tempFile.fileName();
|
||||
qDebug()<< "临时文件路径:" << m_tempFilePath;
|
||||
|
||||
} else {
|
||||
qWarning() << "无法创建临时文件";
|
||||
}
|
||||
|
||||
return packageDetails;
|
||||
}
|
||||
|
||||
|
||||
QStringList aptssUpdater::getPackageSizes()
|
||||
{
|
||||
QStringList packageDetails;
|
||||
|
||||
// 获取可更新包名列表
|
||||
QStringList updateablePackages;
|
||||
for (const QString &pkgInfo : packageName) {
|
||||
updateablePackages << pkgInfo.section(":", 0, 0).trimmed();
|
||||
}
|
||||
|
||||
foreach (const QString &packageName, updateablePackages) {
|
||||
QProcess process; // 在循环内部创建新的QProcess实例
|
||||
|
||||
// 构建新命令(包含包名参数)
|
||||
QString command = QString("/usr/bin/apt download %1 --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\"").arg(packageName);
|
||||
|
||||
process.start("bash", QStringList() << "-c" << command);
|
||||
if (!process.waitForFinished(30000)) { // 30秒超时
|
||||
qWarning() << "获取包信息失败:" << packageName << "(超时)";
|
||||
process.kill();
|
||||
continue;
|
||||
}
|
||||
|
||||
QString output = process.readAllStandardOutput();
|
||||
// 使用正则匹配所有信息
|
||||
// 调整正则表达式匹配分组
|
||||
QRegularExpression regex(R"('([^']+)'\s+(\S+)\s+(\d+)\s+SHA512:([^\s]+))"); // 分组1:URL 分组2:文件名 分组3:大小 分组4:SHA512
|
||||
QRegularExpressionMatch match = regex.match(output);
|
||||
|
||||
if (match.hasMatch()) {
|
||||
QString url = match.captured(1);
|
||||
QString fileName = match.captured(2);
|
||||
QString size = match.captured(3);
|
||||
QString sha512 = match.captured(4);
|
||||
|
||||
// 调整字段顺序:包名 | 大小 | URL | SHA512
|
||||
packageDetails << QString("%1: %2|%3|%4").arg(packageName, size, url, sha512);
|
||||
}
|
||||
}
|
||||
|
||||
qDebug() << "完整包信息:" << packageDetails;
|
||||
return packageDetails;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
QStringList aptssUpdater::getDesktopAppNames()
|
||||
{
|
||||
QStringList appNames;
|
||||
|
||||
// 获取当前系统语言环境
|
||||
QString lang = QLocale().name().replace("_", "-");
|
||||
|
||||
// 遍历所有可更新包(复用已有的临时文件数据)
|
||||
QStringList packages = packageName;
|
||||
|
||||
foreach (const QString &package, packages) {
|
||||
QProcess dpkgProcess; // 在循环内部创建新的QProcess实例
|
||||
|
||||
QString packageName = package.split(":")[0];
|
||||
QString finalName = packageName; // 默认使用包名
|
||||
|
||||
// 获取包文件列表
|
||||
dpkgProcess.start("dpkg", QStringList() << "-L" << packageName);
|
||||
if (!dpkgProcess.waitForFinished(30000)) { // 30秒超时
|
||||
qWarning() << "获取包文件列表失败:" << packageName << "(超时)";
|
||||
dpkgProcess.kill();
|
||||
continue;
|
||||
}
|
||||
|
||||
// 修复:添加这行代码来获取进程输出
|
||||
QString output = dpkgProcess.readAllStandardOutput();
|
||||
|
||||
#if QT_VERSION >= QT_VERSION_CHECK(5, 14, 0)
|
||||
QStringList files = output.split('\n', Qt::SkipEmptyParts);
|
||||
#else
|
||||
QStringList files = output.split('\n', QString::SkipEmptyParts);
|
||||
#endif
|
||||
|
||||
// 先检查常规应用目录
|
||||
QStringList regularDesktopFiles = files.filter("/usr/share/applications/");
|
||||
QString regularAppName;
|
||||
if (!regularDesktopFiles.isEmpty()) {
|
||||
checkDesktopFiles(regularDesktopFiles, regularAppName, lang, packageName);
|
||||
}
|
||||
|
||||
// 如果常规目录没有找到,再检查特殊目录
|
||||
if (regularAppName.isEmpty()) {
|
||||
QStringList specialDesktopFiles = files.filter(QRegularExpression(QString("/opt/apps/%1/entries/applications").arg(packageName)));
|
||||
QString specialAppName;
|
||||
if (!specialDesktopFiles.isEmpty()) {
|
||||
checkDesktopFiles(specialDesktopFiles, specialAppName, lang, packageName);
|
||||
if (!specialAppName.isEmpty()) {
|
||||
finalName = specialAppName;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
finalName = regularAppName;
|
||||
}
|
||||
|
||||
// 输出格式为[软件名|包名]
|
||||
appNames << QString("[%1|%2]").arg(finalName, packageName);
|
||||
}
|
||||
qDebug()<< "应用名称列表:" << appNames;
|
||||
return appNames;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
bool aptssUpdater::checkDesktopFiles(const QStringList &desktopFiles, QString &appName, const QString &lang, const QString &packageName)
|
||||
{
|
||||
QString lastValidName;
|
||||
QRegularExpression noDisplayRe("^NoDisplay=(true|True)");
|
||||
QRegularExpression nameRe("^Name\\[?" + lang + "?\\]?=(.*)");
|
||||
QRegularExpression nameOrigRe("^Name=(.*)");
|
||||
|
||||
foreach (const QString &filePath, desktopFiles) {
|
||||
if (!filePath.endsWith(".desktop")) continue;
|
||||
|
||||
QFile file(filePath);
|
||||
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) continue;
|
||||
|
||||
bool skip = false;
|
||||
QString currentName;
|
||||
|
||||
QTextStream in(&file);
|
||||
while (!in.atEnd()) {
|
||||
QString line = in.readLine().trimmed();
|
||||
|
||||
// 检查NoDisplay属性
|
||||
if (line.startsWith("NoDisplay=")) {
|
||||
if (noDisplayRe.match(line).hasMatch()) {
|
||||
skip = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 优先匹配本地化名称
|
||||
if (currentName.isEmpty()) {
|
||||
QRegularExpressionMatch match = nameRe.match(line);
|
||||
if (match.hasMatch()) {
|
||||
currentName = match.captured(1);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 匹配原始名称
|
||||
match = nameOrigRe.match(line);
|
||||
if (match.hasMatch()) {
|
||||
currentName = match.captured(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!skip && !currentName.isEmpty()) {
|
||||
lastValidName = currentName;
|
||||
}
|
||||
}
|
||||
|
||||
// 处理最终的有效名称
|
||||
if (!lastValidName.isEmpty()) {
|
||||
appName = lastValidName; // 直接赋值而不是使用<<
|
||||
return true;
|
||||
}
|
||||
|
||||
// 回退到包名
|
||||
appName = packageName;
|
||||
return false;
|
||||
}
|
||||
|
||||
QStringList aptssUpdater::getPackageIcons()
|
||||
{
|
||||
QStringList packageIcons;
|
||||
|
||||
// 遍历所有可更新包
|
||||
QStringList packages = packageName;
|
||||
|
||||
foreach (const QString &package, packages) {
|
||||
QProcess dpkgProcess; // 在循环内部创建新的QProcess实例
|
||||
|
||||
QString packageName = package.split(":")[0];
|
||||
QString iconPath = ":/resources/default_icon.png"; // 默认图标
|
||||
|
||||
// 获取包文件列表
|
||||
dpkgProcess.start("dpkg", QStringList() << "-L" << packageName);
|
||||
if (!dpkgProcess.waitForFinished(30000)) { // 30秒超时
|
||||
qWarning() << "获取包文件列表失败:" << packageName << "(超时)";
|
||||
dpkgProcess.kill();
|
||||
packageIcons << QString("%1: %2").arg(packageName, iconPath);
|
||||
continue;
|
||||
}
|
||||
#if QT_VERSION >= QT_VERSION_CHECK(5, 14, 0)
|
||||
QStringList files = QString(dpkgProcess.readAllStandardOutput()).split('\n', Qt::SkipEmptyParts);
|
||||
#else
|
||||
QStringList files = QString(dpkgProcess.readAllStandardOutput()).split('\n', QString::SkipEmptyParts);
|
||||
#endif
|
||||
|
||||
|
||||
// 查找.desktop文件
|
||||
QStringList desktopFiles = files.filter(QRegularExpression("/(usr/share|opt/apps)/.*\\.desktop$"));
|
||||
|
||||
// 从.desktop文件中提取图标
|
||||
foreach (const QString &desktopFile, desktopFiles) {
|
||||
QFile file(desktopFile);
|
||||
if (file.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
||||
QTextStream in(&file);
|
||||
while (!in.atEnd()) {
|
||||
QString line = in.readLine().trimmed();
|
||||
if (line.startsWith("Icon=")) {
|
||||
QString iconName = line.mid(5).trimmed();
|
||||
|
||||
// 处理相对图标名(如Icon=vscode)
|
||||
if (!iconName.contains('/')) {
|
||||
// 查找标准图标路径
|
||||
QStringList iconPaths = {
|
||||
QString("/usr/share/pixmaps/%1.png").arg(iconName),
|
||||
QString("/usr/share/icons/hicolor/48x48/apps/%1.png").arg(iconName),
|
||||
QString("/usr/share/icons/hicolor/scalable/apps/%1.svg").arg(iconName),
|
||||
QString("/opt/apps/%1/entries/icons/hicolor/48x48/apps/%2.png").arg(packageName, iconName)
|
||||
};
|
||||
|
||||
foreach (const QString &path, iconPaths) {
|
||||
if (QFile::exists(path)) {
|
||||
iconPath = path;
|
||||
qDebug() << "找到图标文件:" << path;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 已经是绝对路径
|
||||
if (QFile::exists(iconName)) {
|
||||
iconPath = iconName;
|
||||
qDebug() << "使用绝对路径图标文件:" << iconName;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
file.close();
|
||||
}
|
||||
}
|
||||
|
||||
// 如果.desktop中没有找到图标,尝试直接查找包中的图标文件
|
||||
if (iconPath == ":/resources/default_icon.png") {
|
||||
qDebug() << "未在.desktop文件中找到图标,尝试直接查找包中的图标文件";
|
||||
QStringList iconFiles = files.filter(QRegularExpression("/(usr/share/pixmaps|usr/share/icons|opt/apps/.*/entries/icons)/.*\\.(png|svg)$"));
|
||||
if (!iconFiles.isEmpty()) {
|
||||
iconPath = iconFiles.first();
|
||||
qDebug() << "从包中找到图标文件:" << iconPath;
|
||||
} else {
|
||||
qDebug() << "未在包中找到图标文件,使用默认图标";
|
||||
}
|
||||
}
|
||||
|
||||
qDebug() << "包名:" << packageName << "图标路径:" << iconPath;
|
||||
packageIcons << QString("%1: %2").arg(packageName, iconPath);
|
||||
}
|
||||
|
||||
return packageIcons;
|
||||
}
|
||||
|
||||
|
||||
QJsonArray aptssUpdater::getUpdateInfoAsJson()
|
||||
{
|
||||
QJsonArray jsonArray;
|
||||
|
||||
// 获取所有需要的信息
|
||||
QStringList sizes = getPackageSizes();
|
||||
QStringList names = getDesktopAppNames();
|
||||
QStringList icons = getPackageIcons();
|
||||
|
||||
// 创建包名到各种信息的映射
|
||||
QHash<QString, QHash<QString, QString>> packageInfo;
|
||||
|
||||
// 解析包版本信息
|
||||
for (const QString &pkg : packageName) {
|
||||
QStringList parts = pkg.split(": ");
|
||||
if (parts.size() >= 2) {
|
||||
QString packageName = parts[0];
|
||||
QStringList versions = parts[1].split(" → ");
|
||||
if (versions.size() == 2) {
|
||||
packageInfo[packageName]["current_version"] = versions[0];
|
||||
packageInfo[packageName]["new_version"] = versions[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 解析包详细信息(新增部分)
|
||||
for (const QString &sizeInfo : sizes) {
|
||||
QStringList parts = sizeInfo.split(": ");
|
||||
if (parts.size() == 2) {
|
||||
QString packageName = parts[0];
|
||||
QStringList details = parts[1].split("|");
|
||||
if (details.size() == 3) { // 现在包含大小|URL|SHA512
|
||||
packageInfo[packageName]["size"] = details[0];
|
||||
packageInfo[packageName]["url"] = details[1];
|
||||
packageInfo[packageName]["sha512"] = details[2];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 解析应用名称信息
|
||||
for (const QString &nameInfo : names) {
|
||||
if (nameInfo.startsWith("[") && nameInfo.endsWith("]")) {
|
||||
QString content = nameInfo.mid(1, nameInfo.length() - 2);
|
||||
QStringList parts = content.split("|");
|
||||
if (parts.size() == 2) {
|
||||
QString displayName = parts[0];
|
||||
QString packageName = parts[1];
|
||||
packageInfo[packageName]["display_name"] = displayName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 解析图标信息
|
||||
for (const QString &iconInfo : icons) {
|
||||
QStringList parts = iconInfo.split(": ");
|
||||
if (parts.size() == 2) {
|
||||
QString packageName = parts[0];
|
||||
packageInfo[packageName]["icon"] = parts[1].trimmed();
|
||||
}
|
||||
}
|
||||
|
||||
// 构建JSON数组
|
||||
for (const QString &packageName : packageInfo.keys()) {
|
||||
QJsonObject jsonObj;
|
||||
jsonObj["package"] = packageName;
|
||||
|
||||
// 使用显示名称(如果有),否则使用包名
|
||||
if (packageInfo[packageName].contains("display_name")) {
|
||||
jsonObj["name"] = packageInfo[packageName]["display_name"];
|
||||
} else {
|
||||
jsonObj["name"] = packageName;
|
||||
}
|
||||
|
||||
jsonObj["current_version"] = packageInfo[packageName]["current_version"];
|
||||
jsonObj["new_version"] = packageInfo[packageName]["new_version"];
|
||||
jsonObj["icon"] = packageInfo[packageName]["icon"];
|
||||
jsonObj["ignored"] = false; // 默认不忽略
|
||||
|
||||
// 如果有大小信息也加入
|
||||
if (packageInfo[packageName].contains("size")) {
|
||||
jsonObj["size"] = packageInfo[packageName]["size"];
|
||||
}
|
||||
|
||||
// 在构建JSON对象时添加新字段(在jsonObj中添加):
|
||||
if (packageInfo[packageName].contains("url")) {
|
||||
jsonObj["download_url"] = packageInfo[packageName]["url"];
|
||||
qDebug() << "生成的下载 URL:" << packageInfo[packageName]["url"]; // 检查生成的 URL
|
||||
} else {
|
||||
qWarning() << "未找到下载 URL,包名:" << packageName;
|
||||
jsonObj["download_url"] = ""; // 设置为空字符串以避免崩溃
|
||||
}
|
||||
jsonObj["sha512"] = packageInfo[packageName]["sha512"];
|
||||
jsonArray.append(jsonObj);
|
||||
}
|
||||
qDebug()<<jsonArray;
|
||||
return jsonArray;
|
||||
}
|
||||
|
||||
QStringList aptssUpdater::getApmUpdateablePackages()
|
||||
{
|
||||
QStringList packageDetails;
|
||||
|
||||
// 检查apm命令是否存在
|
||||
QProcess checkProcess;
|
||||
checkProcess.start("which", QStringList() << "apm");
|
||||
if (!checkProcess.waitForFinished(5000) || checkProcess.exitCode() != 0) {
|
||||
qDebug() << "apm命令不存在,跳过APM更新检查";
|
||||
return packageDetails;
|
||||
}
|
||||
|
||||
QProcess process;
|
||||
QString command = R"(env LANGUAGE=en_US /usr/bin/apm list --upgradable | awk 'NR>1')";
|
||||
|
||||
process.start("bash", QStringList() << "-c" << command);
|
||||
if (!process.waitForFinished(30000)) { // 30秒超时
|
||||
qWarning() << "APM process failed to finish within 30 seconds.";
|
||||
process.kill();
|
||||
return packageDetails;
|
||||
}
|
||||
|
||||
QString output = process.readAllStandardOutput();
|
||||
#if QT_VERSION >= QT_VERSION_CHECK(5, 14, 0)
|
||||
QStringList lines = output.split('\n', Qt::SkipEmptyParts);
|
||||
#else
|
||||
QStringList lines = output.split('\n', QString::SkipEmptyParts);
|
||||
#endif
|
||||
|
||||
for (const QString &line : lines) {
|
||||
QRegularExpression regex(R"(([\w\-\+\.]+)/\S+\s+([^\s]+)\s+\S+\s+\[upgradable from: ([^\]]+)\])");
|
||||
QRegularExpressionMatch match = regex.match(line);
|
||||
if (match.hasMatch()) {
|
||||
QString name = match.captured(1);
|
||||
QString newVersion = match.captured(2);
|
||||
QString oldVersion = match.captured(3);
|
||||
|
||||
// 检查版本是否相同,相同则跳过
|
||||
if (newVersion == oldVersion) {
|
||||
qDebug() << "跳过版本相同的APM包:" << name << "(" << oldVersion << "→" << newVersion << ")";
|
||||
continue;
|
||||
}
|
||||
|
||||
// 写入内存列表
|
||||
packageDetails << QString("%1: %2 → %3").arg(name, oldVersion, newVersion);
|
||||
}
|
||||
}
|
||||
|
||||
return packageDetails;
|
||||
}
|
||||
|
||||
QJsonArray aptssUpdater::getApmUpdateInfoAsJson()
|
||||
{
|
||||
QJsonArray jsonArray;
|
||||
|
||||
// 解析APM包版本信息
|
||||
QHash<QString, QHash<QString, QString>> packageInfo;
|
||||
for (const QString &pkg : apmPackageName) {
|
||||
QStringList parts = pkg.split(": ");
|
||||
if (parts.size() >= 2) {
|
||||
QString packageName = parts[0];
|
||||
QStringList versions = parts[1].split(" → ");
|
||||
if (versions.size() == 2) {
|
||||
packageInfo[packageName]["current_version"] = versions[0];
|
||||
packageInfo[packageName]["new_version"] = versions[1];
|
||||
packageInfo[packageName]["source"] = "apm";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 构建JSON数组
|
||||
for (const QString &packageName : packageInfo.keys()) {
|
||||
QJsonObject jsonObj;
|
||||
jsonObj["package"] = packageName;
|
||||
|
||||
// 从APM桌面文件中解析应用名称和图标
|
||||
QString displayName = packageName; // 默认使用包名
|
||||
QString iconPath = ":/resources/default_icon.png"; // 默认图标
|
||||
|
||||
// APM应用的desktop文件路径
|
||||
QString apmDesktopPath = QString("/var/lib/apm/apm/files/ace-env/var/lib/apm/%1/entries/applications").arg(packageName);
|
||||
QDir desktopDir(apmDesktopPath);
|
||||
if (desktopDir.exists()) {
|
||||
// 查找desktop文件
|
||||
QStringList desktopFiles = desktopDir.entryList(QStringList() << "*.desktop", QDir::Files);
|
||||
if (!desktopFiles.isEmpty()) {
|
||||
QString desktopFile = desktopDir.absoluteFilePath(desktopFiles.first());
|
||||
QFile file(desktopFile);
|
||||
if (file.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
||||
QTextStream in(&file);
|
||||
while (!in.atEnd()) {
|
||||
QString line = in.readLine().trimmed();
|
||||
if (line.startsWith("Name=")) {
|
||||
displayName = line.mid(5).trimmed();
|
||||
} else if (line.startsWith("Icon=")) {
|
||||
QString iconName = line.mid(5).trimmed();
|
||||
// 处理图标路径
|
||||
if (!iconName.contains('/')) {
|
||||
// 查找APM包中的图标
|
||||
QString apmIconPath = QString("/var/lib/apm/apm/files/ace-env/var/lib/apm/%1/entries/icons/hicolor/48x48/apps/%2.png").arg(packageName, iconName);
|
||||
if (QFile::exists(apmIconPath)) {
|
||||
iconPath = apmIconPath;
|
||||
}
|
||||
} else {
|
||||
// 已经是绝对路径
|
||||
if (QFile::exists(iconName)) {
|
||||
iconPath = iconName;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
file.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取APM包大小和下载信息
|
||||
QString size = "0";
|
||||
QString url = "";
|
||||
QString sha512 = "";
|
||||
|
||||
QProcess process;
|
||||
QString command = QString("amber-pm-debug /usr/bin/apt -c /opt/durapps/spark-store/bin/apt-fast-conf/aptss-apt.conf download %1 --print-uris").arg(packageName);
|
||||
|
||||
process.start("bash", QStringList() << "-c" << command);
|
||||
if (process.waitForFinished(30000)) { // 30秒超时
|
||||
QString output = process.readAllStandardOutput();
|
||||
// 解析输出格式:'URL' 文件名 大小 SHA512:哈希值
|
||||
QRegularExpression regex(R"('([^']+)'\s+\S+\s+(\d+)\s+SHA512:([^\s]+))");
|
||||
QRegularExpressionMatch match = regex.match(output);
|
||||
|
||||
if (match.hasMatch()) {
|
||||
url = match.captured(1);
|
||||
size = match.captured(2);
|
||||
sha512 = match.captured(3);
|
||||
}
|
||||
}
|
||||
|
||||
jsonObj["name"] = displayName;
|
||||
jsonObj["current_version"] = packageInfo[packageName]["current_version"];
|
||||
jsonObj["new_version"] = packageInfo[packageName]["new_version"];
|
||||
jsonObj["icon"] = iconPath;
|
||||
jsonObj["ignored"] = false; // 默认不忽略
|
||||
jsonObj["source"] = "apm";
|
||||
jsonObj["size"] = size;
|
||||
jsonObj["download_url"] = url;
|
||||
jsonObj["sha512"] = sha512;
|
||||
jsonArray.append(jsonObj);
|
||||
}
|
||||
qDebug()<<"APM更新信息:"<<jsonArray;
|
||||
return jsonArray;
|
||||
}
|
||||
|
||||
QJsonArray aptssUpdater::mergeUpdateInfo()
|
||||
{
|
||||
QJsonArray aptssInfo = getUpdateInfoAsJson();
|
||||
QJsonArray apmInfo = getApmUpdateInfoAsJson();
|
||||
|
||||
// 创建包名到更新信息的映射
|
||||
QHash<QString, QJsonObject> aptssMap;
|
||||
for (const QJsonValue &value : aptssInfo) {
|
||||
QJsonObject obj = value.toObject();
|
||||
QString packageName = obj["package"].toString();
|
||||
obj["source"] = "aptss";
|
||||
aptssMap[packageName] = obj;
|
||||
}
|
||||
|
||||
QHash<QString, QJsonObject> apmMap;
|
||||
for (const QJsonValue &value : apmInfo) {
|
||||
QJsonObject obj = value.toObject();
|
||||
QString packageName = obj["package"].toString();
|
||||
obj["source"] = "apm";
|
||||
apmMap[packageName] = obj;
|
||||
}
|
||||
|
||||
QJsonArray mergedArray;
|
||||
|
||||
// 处理只在aptss中存在的包
|
||||
for (const QString &packageName : aptssMap.keys()) {
|
||||
if (!apmMap.contains(packageName)) {
|
||||
mergedArray.append(aptssMap[packageName]);
|
||||
}
|
||||
}
|
||||
|
||||
// 处理只在apm中存在的包
|
||||
for (const QString &packageName : apmMap.keys()) {
|
||||
if (!aptssMap.contains(packageName)) {
|
||||
mergedArray.append(apmMap[packageName]);
|
||||
}
|
||||
}
|
||||
|
||||
// 处理在两者中都存在的包
|
||||
for (const QString &packageName : aptssMap.keys()) {
|
||||
if (apmMap.contains(packageName)) {
|
||||
QJsonObject aptssObj = aptssMap[packageName];
|
||||
QJsonObject apmObj = apmMap[packageName];
|
||||
|
||||
// 比较版本
|
||||
QString aptssVersion = aptssObj["new_version"].toString();
|
||||
QString apmVersion = apmObj["new_version"].toString();
|
||||
|
||||
// 检查包在两个源中的安装状态
|
||||
bool installedInAptss = isPackageInstalledInAptss(packageName);
|
||||
bool installedInApm = isPackageInstalledInApm(packageName);
|
||||
|
||||
// 判断是否为迁移场景:
|
||||
// 1. 只在 aptss 中安装了包(不在 apm 中安装)
|
||||
// 2. APM 版本更新
|
||||
if (installedInAptss && !installedInApm && apmVersion > aptssVersion) {
|
||||
// 迁移场景:Spark -> APM
|
||||
QJsonObject migrationObj = apmObj;
|
||||
migrationObj["is_migration"] = true;
|
||||
migrationObj["migration_source"] = "aptss";
|
||||
migrationObj["migration_target"] = "apm";
|
||||
migrationObj["aptss_version"] = aptssVersion;
|
||||
mergedArray.append(migrationObj);
|
||||
|
||||
// 同时保留aptss的更新项(如果aptss也有更新)
|
||||
mergedArray.append(aptssObj);
|
||||
} else {
|
||||
// 非迁移场景(共存场景):同时展示两个来源的更新
|
||||
// 包括以下情况:
|
||||
// 1. 同时在 aptss 和 apm 中都安装了包
|
||||
// 2. 只在 apm 中安装了包
|
||||
// 3. APM 版本不更新
|
||||
mergedArray.append(aptssObj);
|
||||
mergedArray.append(apmObj);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
qDebug()<<"合并后的更新信息:"<<mergedArray;
|
||||
return mergedArray;
|
||||
}
|
||||
|
||||
bool aptssUpdater::isPackageInstalledInAptss(const QString &packageName)
|
||||
{
|
||||
QProcess process;
|
||||
QString command = QString("dpkg -s '%1' 2>/dev/null | grep -q 'Status: install ok installed'").arg(packageName);
|
||||
process.start("bash", QStringList() << "-c" << command);
|
||||
if (!process.waitForFinished(5000)) {
|
||||
process.kill();
|
||||
return false;
|
||||
}
|
||||
return process.exitCode() == 0;
|
||||
}
|
||||
|
||||
bool aptssUpdater::isPackageInstalledInApm(const QString &packageName)
|
||||
{
|
||||
QProcess process;
|
||||
process.start("apm", QStringList() << "list" << "--installed");
|
||||
if (!process.waitForFinished(30000)) {
|
||||
process.kill();
|
||||
return false;
|
||||
}
|
||||
QString output = process.readAllStandardOutput();
|
||||
// 解析格式: pkgname/repo,section version arch [flags]
|
||||
// 或: pkgname/repo version arch [flags]
|
||||
QRegularExpression regex(QString("^%1/\\S+(?:,\\S+)?\\s+\\S+\\s+\\S+\\s+\\[").arg(QRegularExpression::escape(packageName)));
|
||||
return regex.match(output).hasMatch();
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
#ifndef APTSSUPDATER_H
|
||||
#define APTSSUPDATER_H
|
||||
|
||||
#include <QWidget>
|
||||
#include <QStringList>
|
||||
#include <QTemporaryFile>
|
||||
#include <QLocale>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonArray>
|
||||
class aptssUpdater : public QWidget
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit aptssUpdater(QWidget *parent = nullptr);
|
||||
|
||||
QStringList getUpdateablePackages(); // 查询可更新包列表及更新内容
|
||||
QStringList getPackageSizes(); // 获取每个包的大小
|
||||
QStringList getDesktopAppNames(); // 获取桌面应用名称列表
|
||||
QStringList getPackageIcons(); // 获取包图标列表
|
||||
QJsonArray getUpdateInfoAsJson(); // 获取更新信息的 JSON 格式
|
||||
QString m_tempFilePath;
|
||||
|
||||
// APM 相关方法
|
||||
QStringList getApmUpdateablePackages(); // 查询 APM 可更新包列表及更新内容
|
||||
QJsonArray getApmUpdateInfoAsJson(); // 获取 APM 更新信息的 JSON 格式
|
||||
QJsonArray mergeUpdateInfo(); // 合并 APTSS 和 APM 的更新信息
|
||||
|
||||
signals:
|
||||
private:
|
||||
bool checkDesktopFiles(const QStringList &desktopFiles, QString &appName, const QString &lang, const QString &packageName);
|
||||
QStringList packageName;
|
||||
QStringList apmPackageName; // APM 包列表
|
||||
|
||||
// 检查包安装状态的方法
|
||||
bool isPackageInstalledInAptss(const QString &packageName); // 检查包是否在 aptss 中已安装
|
||||
bool isPackageInstalledInApm(const QString &packageName); // 检查包是否在 apm 中已安装
|
||||
};
|
||||
|
||||
#endif // APTSSUPDATER_H
|
||||
@@ -0,0 +1,134 @@
|
||||
#include "downloadmanager.h"
|
||||
#include <QFileInfo>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QRegularExpression>
|
||||
#include <QDebug>
|
||||
|
||||
DownloadManager::DownloadManager(QObject *parent) : QObject(parent)
|
||||
{
|
||||
cleanupTempFiles();
|
||||
}
|
||||
|
||||
DownloadManager::~DownloadManager()
|
||||
{
|
||||
// 终止并清理所有正在运行的下载进程
|
||||
for (auto it = m_processes.begin(); it != m_processes.end(); ) {
|
||||
QProcess *process = it.value();
|
||||
if (process->state() != QProcess::NotRunning) {
|
||||
process->kill(); // 立即终止进程
|
||||
process->waitForFinished(3000); // 最多等待3秒
|
||||
}
|
||||
process->deleteLater();
|
||||
it = m_processes.erase(it);
|
||||
}
|
||||
}
|
||||
|
||||
void DownloadManager::startDownload(const QString &packageName, const QString &url, const QString &outputPath)
|
||||
{
|
||||
if (m_processes.contains(packageName)) {
|
||||
qWarning() << packageName << " is already downloading.";
|
||||
return;
|
||||
}
|
||||
|
||||
QString metalinkUrl = url + ".metalink";
|
||||
QFileInfo fileInfo(outputPath);
|
||||
|
||||
QStringList arguments = {
|
||||
"--enable-rpc=false",
|
||||
"--console-log-level=warn",
|
||||
"--async-dns=false",
|
||||
"--summary-interval=1",
|
||||
"--allow-overwrite=true",
|
||||
"--connect-timeout=30",
|
||||
"--max-tries=3",
|
||||
"--dir=" + fileInfo.absolutePath(),
|
||||
"--out=" + fileInfo.fileName(),
|
||||
metalinkUrl
|
||||
};
|
||||
|
||||
QProcess *process = new QProcess(this);
|
||||
m_processes.insert(packageName, process);
|
||||
|
||||
// 新增:准备日志文件
|
||||
QString logPath = QString("/tmp/%1_download.log").arg(packageName);
|
||||
QFile *logFile = new QFile(logPath, process);
|
||||
if (logFile->open(QIODevice::Append | QIODevice::Text)) {
|
||||
// 设置权限为777
|
||||
QFile::setPermissions(logPath, QFile::ReadOwner | QFile::WriteOwner | QFile::ExeOwner |
|
||||
QFile::ReadGroup | QFile::WriteGroup | QFile::ExeGroup |
|
||||
QFile::ReadOther | QFile::WriteOther | QFile::ExeOther);
|
||||
connect(process, &QProcess::readyReadStandardOutput, this, [this, packageName, process, logFile]() {
|
||||
while (process->canReadLine()) {
|
||||
QString line = QString::fromUtf8(process->readLine()).trimmed();
|
||||
// 写入日志
|
||||
logFile->write(line.toUtf8() + '\n');
|
||||
logFile->flush();
|
||||
QRegularExpression regex(R"(\((\d+)%\))");
|
||||
QRegularExpressionMatch match = regex.match(line);
|
||||
if (match.hasMatch()) {
|
||||
int progress = match.captured(1).toInt();
|
||||
emit downloadProgress(packageName, progress);
|
||||
}
|
||||
}
|
||||
});
|
||||
connect(process, &QProcess::readyReadStandardError, this, [process, logFile]() {
|
||||
QByteArray err = process->readAllStandardError();
|
||||
logFile->write(err);
|
||||
logFile->flush();
|
||||
});
|
||||
}
|
||||
|
||||
connect(process, QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished),
|
||||
this, [this, packageName, outputPath, logFile](int exitCode, QProcess::ExitStatus status) {
|
||||
bool success = (exitCode == 0 && status == QProcess::NormalExit);
|
||||
if (!success) {
|
||||
qWarning() << "Download failed for" << packageName << "exit code:" << exitCode;
|
||||
}
|
||||
|
||||
removeAria2Files(outputPath); // 清理残留 .aria2
|
||||
emit downloadFinished(packageName, success);
|
||||
|
||||
if (logFile) logFile->close();
|
||||
|
||||
QProcess *proc = m_processes.take(packageName);
|
||||
if (proc) proc->deleteLater();
|
||||
});
|
||||
|
||||
process->start("aria2c", arguments);
|
||||
}
|
||||
|
||||
void DownloadManager::cancelDownload(const QString &packageName)
|
||||
{
|
||||
if (!m_processes.contains(packageName)) return;
|
||||
|
||||
QProcess *process = m_processes.take(packageName);
|
||||
if (process) {
|
||||
process->kill(); // 立即终止
|
||||
process->waitForFinished(3000); // 最多等待3秒
|
||||
process->deleteLater();
|
||||
}
|
||||
|
||||
emit downloadFinished(packageName, false); // 显式通知取消
|
||||
|
||||
}
|
||||
|
||||
void DownloadManager::removeAria2Files(const QString &filePath)
|
||||
{
|
||||
QString ariaFile = filePath + ".aria2";
|
||||
QFile::remove(ariaFile);
|
||||
}
|
||||
|
||||
bool DownloadManager::isDownloading(const QString &packageName) const
|
||||
{
|
||||
return m_processes.contains(packageName);
|
||||
}
|
||||
|
||||
void DownloadManager::cleanupTempFiles()
|
||||
{
|
||||
QDir tempDir(QDir::tempPath());
|
||||
QStringList leftovers = tempDir.entryList(QStringList() << "*.aria2", QDir::Files);
|
||||
for (const QString &f : leftovers) {
|
||||
tempDir.remove(f);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
#ifndef DOWNLOADMANAGER_H
|
||||
#define DOWNLOADMANAGER_H
|
||||
|
||||
#include <QObject>
|
||||
#include <QMap>
|
||||
#include <QProcess>
|
||||
|
||||
class DownloadManager : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit DownloadManager(QObject *parent = nullptr);
|
||||
~DownloadManager();
|
||||
void startDownload(const QString &packageName, const QString &url, const QString &outputPath);
|
||||
void cancelDownload(const QString &packageName);
|
||||
bool isDownloading(const QString &packageName) const;
|
||||
|
||||
signals:
|
||||
void downloadProgress(const QString &packageName, int progress);
|
||||
void downloadFinished(const QString &packageName, bool success);
|
||||
|
||||
private:
|
||||
void cleanupTempFiles();
|
||||
void removeAria2Files(const QString &filePath);
|
||||
|
||||
QMap<QString, QProcess*> m_processes;
|
||||
};
|
||||
|
||||
#endif // DOWNLOADMANAGER_H
|
||||
@@ -0,0 +1,9 @@
|
||||
<RCC>
|
||||
<qresource prefix="/">
|
||||
<file>../resources/down_arrow.svg</file>
|
||||
<file>../resources/default_icon.svg</file>
|
||||
<file>../resources/spark-update-tool.svg</file>
|
||||
<file>../resources/128*128/spark-update-tool.png</file>
|
||||
<file>../resources/default_icon.png</file>
|
||||
</qresource>
|
||||
</RCC>
|
||||
@@ -0,0 +1,119 @@
|
||||
#include "ignoreconfig.h"
|
||||
#include <QStandardPaths>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QTextStream>
|
||||
#include <QDebug>
|
||||
|
||||
IgnoreConfig::IgnoreConfig(QObject *parent)
|
||||
: QObject(parent)
|
||||
{
|
||||
QString configDir;
|
||||
QByteArray sudoUserHomeEnv = qgetenv("SUDO_USER_HOME");
|
||||
|
||||
if (!sudoUserHomeEnv.isEmpty()) {
|
||||
configDir = QString::fromLocal8Bit(sudoUserHomeEnv) + "/.config";
|
||||
} else {
|
||||
configDir = QStandardPaths::writableLocation(QStandardPaths::ConfigLocation);
|
||||
}
|
||||
|
||||
QDir dir(configDir);
|
||||
if (!dir.exists()) {
|
||||
dir.mkpath(".");
|
||||
}
|
||||
m_configFilePath = dir.filePath("spark-store/ignored_apps.conf");
|
||||
|
||||
// 确保目录存在
|
||||
QFileInfo fileInfo(m_configFilePath);
|
||||
QDir configDirPath = fileInfo.dir();
|
||||
if (!configDirPath.exists()) {
|
||||
configDirPath.mkpath(".");
|
||||
}
|
||||
|
||||
// 加载现有配置
|
||||
loadConfig();
|
||||
|
||||
// 输出忽略列表到 qDebug
|
||||
printIgnoredApps();
|
||||
}
|
||||
|
||||
void IgnoreConfig::addIgnoredApp(const QString &packageName, const QString &version)
|
||||
{
|
||||
m_ignoredApps.insert(qMakePair(packageName, version));
|
||||
saveConfig();
|
||||
}
|
||||
|
||||
void IgnoreConfig::removeIgnoredApp(const QString &packageName, const QString &version)
|
||||
{
|
||||
m_ignoredApps.remove(qMakePair(packageName, version));
|
||||
saveConfig();
|
||||
}
|
||||
|
||||
bool IgnoreConfig::isAppIgnored(const QString &packageName, const QString &version) const
|
||||
{
|
||||
return m_ignoredApps.contains(qMakePair(packageName, version));
|
||||
}
|
||||
|
||||
QSet<QPair<QString, QString>> IgnoreConfig::getIgnoredApps() const
|
||||
{
|
||||
return m_ignoredApps;
|
||||
}
|
||||
|
||||
void IgnoreConfig::printIgnoredApps() const
|
||||
{
|
||||
qDebug() << "=== 忽略的应用列表 ===";
|
||||
qDebug() << "配置文件路径:" << m_configFilePath;
|
||||
|
||||
if (m_ignoredApps.isEmpty()) {
|
||||
qDebug() << "没有忽略的应用";
|
||||
} else {
|
||||
for (const auto &app : m_ignoredApps) {
|
||||
qDebug() << "忽略的应用:" << app.first << "版本:" << app.second;
|
||||
}
|
||||
}
|
||||
qDebug() << "====================";
|
||||
}
|
||||
|
||||
bool IgnoreConfig::saveConfig()
|
||||
{
|
||||
QFile file(m_configFilePath);
|
||||
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
|
||||
qDebug() << "无法打开配置文件进行写入:" << m_configFilePath;
|
||||
return false;
|
||||
}
|
||||
|
||||
QTextStream out(&file);
|
||||
for (const auto &app : m_ignoredApps) {
|
||||
out << app.first << "|" << app.second << "\n";
|
||||
}
|
||||
file.close();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool IgnoreConfig::loadConfig()
|
||||
{
|
||||
QFile file(m_configFilePath);
|
||||
if (!file.exists()) {
|
||||
// 配置文件不存在,这是正常的,返回true表示没有错误
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
||||
qDebug() << "无法打开配置文件进行读取:" << m_configFilePath;
|
||||
return false;
|
||||
}
|
||||
|
||||
m_ignoredApps.clear();
|
||||
QTextStream in(&file);
|
||||
while (!in.atEnd()) {
|
||||
QString line = in.readLine().trimmed();
|
||||
if (!line.isEmpty()) {
|
||||
QStringList parts = line.split('|');
|
||||
if (parts.size() == 2) {
|
||||
m_ignoredApps.insert(qMakePair(parts[0], parts[1]));
|
||||
}
|
||||
}
|
||||
}
|
||||
file.close();
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
#ifndef IGNORECONFIG_H
|
||||
#define IGNORECONFIG_H
|
||||
|
||||
#include <QObject>
|
||||
#include <QSet>
|
||||
#include <QString>
|
||||
#include <QPair>
|
||||
|
||||
class IgnoreConfig : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit IgnoreConfig(QObject *parent = nullptr);
|
||||
|
||||
// 添加忽略的应用(包名和版本号)
|
||||
void addIgnoredApp(const QString &packageName, const QString &version);
|
||||
|
||||
// 移除忽略的应用
|
||||
void removeIgnoredApp(const QString &packageName, const QString &version);
|
||||
|
||||
// 检查应用是否被忽略
|
||||
bool isAppIgnored(const QString &packageName, const QString &version) const;
|
||||
|
||||
// 获取所有被忽略的应用
|
||||
QSet<QPair<QString, QString>> getIgnoredApps() const;
|
||||
|
||||
// 输出所有被忽略的应用到 qDebug
|
||||
void printIgnoredApps() const;
|
||||
|
||||
// 保存配置到文件
|
||||
bool saveConfig();
|
||||
|
||||
// 从文件加载配置
|
||||
bool loadConfig();
|
||||
|
||||
private:
|
||||
QSet<QPair<QString, QString>> m_ignoredApps;
|
||||
QString m_configFilePath;
|
||||
};
|
||||
|
||||
#endif // IGNORECONFIG_H
|
||||
@@ -0,0 +1,88 @@
|
||||
#include "mainwindow.h"
|
||||
#include <QApplication>
|
||||
#include <QProcess>
|
||||
#include <QMessageBox>
|
||||
#include <unistd.h> // for geteuid
|
||||
#include <cstdlib> // for getenv
|
||||
#include <QDebug> // For debugging output
|
||||
|
||||
bool isRoot() {
|
||||
return geteuid() == 0;
|
||||
}
|
||||
|
||||
bool elevateToRoot() {
|
||||
QString program = QCoreApplication::applicationFilePath();
|
||||
qDebug() << "Current application path:" << program;
|
||||
|
||||
QByteArray display = qgetenv("DISPLAY");
|
||||
QByteArray xauthority = qgetenv("XAUTHORITY");
|
||||
QByteArray home = qgetenv("HOME"); // 获取原始用户的 HOME 目境变量
|
||||
|
||||
QStringList args;
|
||||
args << "env"
|
||||
<< "DISPLAY=" + display
|
||||
<< "XAUTHORITY=" + xauthority
|
||||
<< "SUDO_USER_HOME=" + home // 传递原始用户的 HOME 路径
|
||||
<< program;
|
||||
|
||||
QProcess process;
|
||||
process.setProgram("pkexec");
|
||||
process.setArguments(args);
|
||||
|
||||
qDebug() << "Attempting to elevate using pkexec with arguments:" << args;
|
||||
|
||||
process.start();
|
||||
if (!process.waitForStarted(5000)) {
|
||||
qDebug() << "Failed to start pkexec.";
|
||||
return false;
|
||||
}
|
||||
|
||||
// 阻塞等待提权进程退出(比如主程序窗口关闭)
|
||||
if (!process.waitForFinished(-1)) { // 等待直到新进程退出
|
||||
qDebug() << "pkexec process waitForFinished failed.";
|
||||
return false;
|
||||
}
|
||||
|
||||
int exitCode = process.exitCode();
|
||||
QProcess::ExitStatus exitStatus = process.exitStatus();
|
||||
|
||||
qDebug() << "pkexec exit code:" << exitCode;
|
||||
qDebug() << "pkexec exit status:" << exitStatus;
|
||||
qDebug() << "pkexec stderr:" << process.readAllStandardError();
|
||||
|
||||
return (exitStatus == QProcess::NormalExit && exitCode == 0);
|
||||
}
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
// 必须在 QGuiApplication 实例创建之前调用
|
||||
// QGuiApplication::setHighDpiScaleFactorRoundingPolicy(Qt::HighDpiScaleFactorRoundingPolicy::PassThrough);
|
||||
|
||||
QApplication a(argc, argv);
|
||||
a.setWindowIcon(QIcon(":/resources/128*128/spark-update-tool.png"));
|
||||
if (!isRoot()) {
|
||||
qDebug() << "Not running as root. Attempting to elevate...";
|
||||
if (!elevateToRoot()) {
|
||||
qDebug() << "Elevation failed or pkexec command was not executed successfully.";
|
||||
QMessageBox::critical(nullptr,
|
||||
"权限不足",
|
||||
"提权失败!\n\n您的系统可能不支持 `pkexec` 或 `polkit` 配置不正确,"
|
||||
"或者您取消了授权。\n\n请尝试使用 `sudo` 命令运行此程序:"
|
||||
"\n\n在终端中输入:\n`sudo " + QCoreApplication::applicationName() + "`");
|
||||
return 0; // 提权失败,退出程序
|
||||
} else {
|
||||
// 如果 elevateToRoot 返回 true,说明 pkexec 命令本身执行成功
|
||||
// 但这并不意味着原始程序以 root 权限启动了
|
||||
// 因为 elevateToRoot 启动的是一个新的进程,当前进程应该退出
|
||||
// 否则会并行运行两个程序实例
|
||||
qDebug() << "pkexec command executed successfully (new process likely started). Exiting current process.";
|
||||
return 0; // 当前非root进程退出
|
||||
}
|
||||
} else {
|
||||
qDebug() << "Running as root.";
|
||||
}
|
||||
|
||||
MainWindow w;
|
||||
w.show();
|
||||
return a.exec();
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
#include "mainwindow.h"
|
||||
#include "./ui_mainwindow.h"
|
||||
#include <QProcess>
|
||||
#include <QMessageBox>
|
||||
#include <QProgressDialog>
|
||||
#include <QtConcurrent> // 新增
|
||||
#include <QFutureWatcher> // 新增
|
||||
#include <QIcon>
|
||||
#include <qicon.h>
|
||||
#include <unistd.h> // for geteuid
|
||||
MainWindow::MainWindow(QWidget *parent)
|
||||
: QMainWindow(parent)
|
||||
, ui(new Ui::MainWindow)
|
||||
, m_model(new AppListModel(this))
|
||||
, m_delegate(new AppDelegate(this))
|
||||
, m_ignoreConfig(new IgnoreConfig(this))
|
||||
{
|
||||
QIcon icon(":/resources/128*128/spark-update-tool.png");
|
||||
setWindowIcon(icon);
|
||||
QProgressDialog *progressDialog = new QProgressDialog("正在与服务器通信,获取更新信息中...", QString(), 0, 0, this);
|
||||
progressDialog->setWindowModality(Qt::ApplicationModal);
|
||||
progressDialog->setCancelButton(nullptr);
|
||||
progressDialog->setWindowTitle("请稍候");
|
||||
progressDialog->setMinimumDuration(0);
|
||||
progressDialog->setWindowFlags(progressDialog->windowFlags() & ~Qt::WindowCloseButtonHint); // 禁用关闭按钮
|
||||
progressDialog->show();
|
||||
//异步执行runAptssUpgrade
|
||||
QFutureWatcher<void> *watcher = new QFutureWatcher<void>(this);
|
||||
connect(watcher, &QFutureWatcher<void>::finished, this, [=]() {
|
||||
progressDialog->close();
|
||||
progressDialog->deleteLater();
|
||||
watcher->deleteLater();
|
||||
ui->setupUi(this);
|
||||
QIcon icon(":/resources/128*128/spark-update-tool.png");
|
||||
setWindowIcon(icon);
|
||||
// 创建 QListView 并设置父控件为 ui->appWidget
|
||||
listView = new QListView(ui->appWidget);
|
||||
listView->setModel(m_model);
|
||||
listView->setItemDelegate(m_delegate);
|
||||
|
||||
// 新增:确保 delegate 拥有 model 指针
|
||||
m_delegate->setModel(m_model);
|
||||
|
||||
// 设置 QListView 填充 ui->appWidget
|
||||
QVBoxLayout *layout = new QVBoxLayout(ui->appWidget);
|
||||
layout->addWidget(listView);
|
||||
layout->setContentsMargins(0, 0, 0, 0);
|
||||
connect(m_delegate, &AppDelegate::updateDisplay, this, [=](const QString &packageName) {
|
||||
for (int i = 0; i < m_model->rowCount(); ++i) {
|
||||
QModelIndex index = m_model->index(i);
|
||||
if (index.data(Qt::UserRole + 1).toString() == packageName) {
|
||||
m_model->dataChanged(index, index); // 刷新该行
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 连接应用委托的信号
|
||||
connect(m_delegate, &AppDelegate::ignoreApp, this, &MainWindow::onIgnoreApp);
|
||||
connect(m_delegate, &AppDelegate::unignoreApp, this, &MainWindow::onUnignoreApp);
|
||||
|
||||
// 新增:点击“更新全部”按钮批量下载
|
||||
connect(ui->updatePushButton, &QPushButton::clicked, this, [=](){
|
||||
qDebug()<<"更新按钮被点击";
|
||||
if (m_delegate->getSelectedPackages().isEmpty()) {
|
||||
// 没有选中任何应用,更新全部
|
||||
m_delegate->startDownloadForAll();
|
||||
} else {
|
||||
// 有选中应用,更新选中
|
||||
m_delegate->startDownloadForSelected();
|
||||
m_delegate->clearSelection();
|
||||
updateButtonText();
|
||||
}
|
||||
});
|
||||
|
||||
// 新增:监听选择变化
|
||||
connect(m_delegate, &AppDelegate::updateDisplay, this, &MainWindow::handleSelectionChanged);
|
||||
|
||||
checkUpdates();
|
||||
// 新增:监听搜索框文本变化
|
||||
connect(ui->searchPlainTextEdit, &QPlainTextEdit::textChanged, this, [=]() {
|
||||
QString keyword = ui->searchPlainTextEdit->toPlainText();
|
||||
filterAppsByKeyword(keyword);
|
||||
});
|
||||
initStyle();
|
||||
|
||||
// 确保搜索框内容为空,placeholder 能显示
|
||||
ui->searchPlainTextEdit->clear();
|
||||
});
|
||||
|
||||
// 启动异步任务
|
||||
watcher->setFuture(QtConcurrent::run([this](){
|
||||
runAptssUpgrade();
|
||||
}));
|
||||
QScreen *screen = QGuiApplication::screenAt(QCursor::pos());
|
||||
if (!screen) screen = QGuiApplication::primaryScreen();
|
||||
QRect screenGeometry = screen->geometry();
|
||||
int x = screenGeometry.x() + (screenGeometry.width() - this->width()) / 2;
|
||||
int y = screenGeometry.y() + (screenGeometry.height() - this->height()) / 2;
|
||||
this->move(x, y);
|
||||
}
|
||||
//初始化控件样式
|
||||
void MainWindow::initStyle()
|
||||
{
|
||||
//设置窗口标题
|
||||
this->setWindowTitle("软件更新中心");
|
||||
|
||||
//查询框样式
|
||||
ui->searchPlainTextEdit->setStyleSheet(R"(
|
||||
QPlainTextEdit {
|
||||
background-color: #FFFFFF;
|
||||
border: 1px solid #E5E7EB;
|
||||
border-radius: 4px;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
font-size: 9px;
|
||||
line-height: 1.4;
|
||||
color: #9CA3AF;
|
||||
}
|
||||
QPlainTextEdit[placeholderText]:empty {
|
||||
color: #9CA3AF;
|
||||
}
|
||||
)");
|
||||
|
||||
ui->searchPlainTextEdit->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
||||
ui->searchPlainTextEdit->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
||||
|
||||
//筛选框样式
|
||||
ui->FilterComboBox->setStyleSheet(R"(
|
||||
QComboBox {
|
||||
background-color: #FFFFFF;
|
||||
border: 1px solid #E5E7EB;
|
||||
border-radius: 4px;
|
||||
color: #4B5563;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
QComboBox::drop-down {
|
||||
border: none;
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
QComboBox::down-arrow {
|
||||
image: url(:/resources/down_arrow.svg);
|
||||
width: 12px;
|
||||
height: 16px;
|
||||
}
|
||||
QComboBox QAbstractItemView {
|
||||
background-color: #FFFFFF;
|
||||
border: 1px solid #E5E7EB;
|
||||
color: #4B5563;
|
||||
selection-background-color: #F3F4F6;
|
||||
selection-color: #111827;
|
||||
}
|
||||
)");
|
||||
|
||||
//更新软件按钮样式
|
||||
ui->updatePushButton->setStyleSheet(R"(
|
||||
QPushButton {
|
||||
background-color: #2563EB;
|
||||
color: #FFFFFF;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
padding: 6px 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
QPushButton:hover {
|
||||
background-color: #1D4ED8; /* 深一点的 hover 效果,可选 */
|
||||
}
|
||||
|
||||
QPushButton:pressed {
|
||||
background-color: #1E40AF; /* 按下效果,可选 */
|
||||
}
|
||||
|
||||
QPushButton:disabled {
|
||||
background-color: #A5B4FC;
|
||||
color: #F9FAFB;
|
||||
}
|
||||
)");
|
||||
|
||||
//设置背景填充颜色
|
||||
ui->backgroundWidget->setStyleSheet(R"(
|
||||
QWidget {
|
||||
background-color: #FFFFFF;
|
||||
border-radius: 12px;
|
||||
}
|
||||
)");
|
||||
|
||||
//设置主背景颜色
|
||||
this->setStyleSheet("background-color: #F8FAFC;");
|
||||
|
||||
// 添加滚动条样式
|
||||
this->setStyleSheet(R"(
|
||||
QScrollBar:vertical {
|
||||
background: #F3F4F6;
|
||||
width: 8px;
|
||||
margin: 0px;
|
||||
}
|
||||
QScrollBar::handle:vertical {
|
||||
background: #D1D5DB;
|
||||
border-radius: 4px;
|
||||
min-height: 30px;
|
||||
}
|
||||
QScrollBar::handle:vertical:hover {
|
||||
background: #9CA3AF;
|
||||
}
|
||||
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {
|
||||
background: none;
|
||||
height: 0px;
|
||||
}
|
||||
|
||||
QScrollBar:horizontal {
|
||||
background: #F3F4F6;
|
||||
height: 8px;
|
||||
margin: 0px;
|
||||
}
|
||||
QScrollBar::handle:horizontal {
|
||||
background: #D1D5DB;
|
||||
border-radius: 4px;
|
||||
min-width: 30px;
|
||||
}
|
||||
QScrollBar::handle:horizontal:hover {
|
||||
background: #9CA3AF;
|
||||
}
|
||||
)");
|
||||
}
|
||||
void MainWindow::checkUpdates()
|
||||
{
|
||||
aptssUpdater updater;
|
||||
QJsonArray updateInfo = updater.mergeUpdateInfo();
|
||||
|
||||
// 分离正常应用和忽略应用
|
||||
QJsonArray normalApps;
|
||||
QJsonArray ignoredApps;
|
||||
|
||||
for (const auto &item : updateInfo) {
|
||||
QJsonObject obj = item.toObject();
|
||||
QString packageName = obj["package"].toString();
|
||||
QString newVersion = obj["new_version"].toString();
|
||||
|
||||
// 检查应用是否被忽略
|
||||
if (m_ignoreConfig->isAppIgnored(packageName, newVersion)) {
|
||||
// 标记为忽略状态
|
||||
obj["ignored"] = true;
|
||||
ignoredApps.append(obj);
|
||||
} else {
|
||||
obj["ignored"] = false;
|
||||
normalApps.append(obj);
|
||||
}
|
||||
}
|
||||
|
||||
// 合并数组:正常应用在前,忽略应用在后
|
||||
QJsonArray finalApps;
|
||||
for (const auto &item : normalApps) {
|
||||
finalApps.append(item);
|
||||
}
|
||||
for (const auto &item : ignoredApps) {
|
||||
finalApps.append(item);
|
||||
}
|
||||
|
||||
m_allApps = finalApps; // 保存所有应用数据
|
||||
m_model->setUpdateData(finalApps);
|
||||
|
||||
for (const auto &item : finalApps) {
|
||||
QJsonObject obj = item.toObject();
|
||||
qDebug() << "模型设置的包名:" << obj["package"].toString() << "忽略状态:" << obj["ignored"].toBool() << "来源:" << obj["source"].toString();
|
||||
qDebug() << "模型设置的下载 URL:" << obj["download_url"].toString(); // 检查模型数据
|
||||
}
|
||||
}
|
||||
|
||||
// 新增:根据关键字过滤应用
|
||||
void MainWindow::filterAppsByKeyword(const QString &keyword)
|
||||
{
|
||||
if (keyword.trimmed().isEmpty()) {
|
||||
m_model->setUpdateData(m_allApps);
|
||||
return;
|
||||
}
|
||||
|
||||
// 分离正常应用和忽略应用
|
||||
QJsonArray normalApps;
|
||||
QJsonArray ignoredApps;
|
||||
|
||||
for (const auto &item : m_allApps) {
|
||||
QJsonObject obj = item.toObject();
|
||||
// 可根据需要匹配更多字段
|
||||
QString name = obj.value("name").toString();
|
||||
QString package = obj.value("package").toString();
|
||||
if (name.contains(keyword, Qt::CaseInsensitive) ||
|
||||
package.contains(keyword, Qt::CaseInsensitive)) {
|
||||
|
||||
// 检查是否为忽略状态
|
||||
if (obj.value("ignored").toBool()) {
|
||||
ignoredApps.append(item);
|
||||
} else {
|
||||
normalApps.append(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 合并数组:正常应用在前,忽略应用在后
|
||||
QJsonArray filtered;
|
||||
for (const auto &item : normalApps) {
|
||||
filtered.append(item);
|
||||
}
|
||||
for (const auto &item : ignoredApps) {
|
||||
filtered.append(item);
|
||||
}
|
||||
|
||||
m_model->setUpdateData(filtered);
|
||||
}
|
||||
|
||||
void MainWindow::runAptssUpgrade()
|
||||
{
|
||||
// 检查aptss命令是否存在
|
||||
QProcess checkProcess;
|
||||
checkProcess.start("which", QStringList() << "aptss");
|
||||
if (checkProcess.waitForFinished(5000) && checkProcess.exitCode() == 0) {
|
||||
// aptss存在,执行aptss ssupdate
|
||||
QProcess process;
|
||||
|
||||
// 检查是否已经是root用户,如果是则直接执行命令,否则使用sudo
|
||||
if (geteuid() == 0) {
|
||||
// root用户直接执行
|
||||
process.start("aptss", QStringList() << "ssupdate");
|
||||
} else {
|
||||
// 非root用户使用sudo
|
||||
process.start("sudo", QStringList() << "aptss" << "ssupdate");
|
||||
}
|
||||
|
||||
if (!process.waitForStarted(5000)) {
|
||||
qDebug() << "无法启动 aptss ssupdate";
|
||||
return;
|
||||
}
|
||||
process.write("n\n");
|
||||
process.closeWriteChannel();
|
||||
|
||||
// 设置超时时间,避免无限等待
|
||||
if (!process.waitForFinished(30000)) { // 30秒超时
|
||||
qDebug() << "aptss ssupdate 执行超时";
|
||||
process.kill(); // 强制终止进程
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.exitCode() != 0) {
|
||||
qDebug() << "执行 aptss ssupdate 失败,请检查系统环境或稍后再试。";
|
||||
}
|
||||
} else {
|
||||
qDebug() << "aptss命令不存在,跳过aptss ssupdate";
|
||||
}
|
||||
|
||||
// 检查apm命令是否存在,如果存在则执行apm update
|
||||
QProcess checkApmProcess;
|
||||
checkApmProcess.start("which", QStringList() << "apm");
|
||||
if (checkApmProcess.waitForFinished(5000) && checkApmProcess.exitCode() == 0) {
|
||||
qDebug() << "apm命令存在,执行apm update";
|
||||
QProcess apmProcess;
|
||||
|
||||
// 检查是否已经是root用户,如果是则直接执行命令,否则使用sudo
|
||||
if (geteuid() == 0) {
|
||||
// root用户直接执行
|
||||
apmProcess.start("apm", QStringList() << "update");
|
||||
} else {
|
||||
// 非root用户使用sudo
|
||||
apmProcess.start("sudo", QStringList() << "apm" << "update");
|
||||
}
|
||||
|
||||
if (!apmProcess.waitForStarted(5000)) {
|
||||
qDebug() << "无法启动 apm update";
|
||||
return;
|
||||
}
|
||||
|
||||
// 设置超时时间,避免无限等待
|
||||
if (!apmProcess.waitForFinished(30000)) { // 30秒超时
|
||||
qDebug() << "apm update 执行超时";
|
||||
apmProcess.kill(); // 强制终止进程
|
||||
return;
|
||||
}
|
||||
|
||||
if (apmProcess.exitCode() != 0) {
|
||||
qDebug() << "执行 apm update 失败";
|
||||
}
|
||||
} else {
|
||||
qDebug() << "apm命令不存在,跳过apm update";
|
||||
}
|
||||
}
|
||||
void MainWindow::closeEvent(QCloseEvent *event)
|
||||
{
|
||||
// 检查是否正在进行更新
|
||||
bool isUpdating = false;
|
||||
|
||||
// 通过AppDelegate检查是否有正在下载或安装的应用
|
||||
const QHash<QString, DownloadInfo>& downloads = m_delegate->getDownloads();
|
||||
for (auto it = downloads.constBegin(); it != downloads.constEnd(); ++it) {
|
||||
if (it.value().isDownloading || it.value().isInstalling) {
|
||||
isUpdating = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果正在更新,才显示确认对话框
|
||||
if (isUpdating) {
|
||||
QMessageBox::StandardButton reply = QMessageBox::question(this, "确认关闭", "正在更新,是否确认关闭窗口?", QMessageBox::Yes | QMessageBox::No);
|
||||
|
||||
if (reply == QMessageBox::Yes) {
|
||||
event->accept();
|
||||
} else {
|
||||
event->ignore();
|
||||
}
|
||||
} else {
|
||||
// 如果没有更新,直接关闭窗口
|
||||
event->accept();
|
||||
}
|
||||
}
|
||||
void MainWindow::handleUpdateFinished(bool success)
|
||||
{
|
||||
if (success) {
|
||||
// 更新成功时的处理逻辑
|
||||
QMessageBox::information(this, "更新完成", "软件更新已成功完成!");
|
||||
} else {
|
||||
// 更新失败时的处理逻辑
|
||||
QMessageBox::warning(this, "更新失败", "软件更新过程中出现错误,请稍后再试。");
|
||||
}
|
||||
|
||||
// 刷新应用列表
|
||||
checkUpdates();
|
||||
}
|
||||
|
||||
MainWindow::~MainWindow()
|
||||
{
|
||||
delete ui;
|
||||
}
|
||||
|
||||
// 新增:更新按钮文本
|
||||
void MainWindow::updateButtonText() {
|
||||
int selectedCount = m_delegate->getSelectedPackages().size();
|
||||
if (selectedCount > 0) {
|
||||
ui->updatePushButton->setText(QString("更新选中(%1)").arg(selectedCount));
|
||||
} else {
|
||||
ui->updatePushButton->setText("更新全部");
|
||||
}
|
||||
}
|
||||
|
||||
// 新增:处理选择变化
|
||||
void MainWindow::handleSelectionChanged() {
|
||||
updateButtonText();
|
||||
}
|
||||
|
||||
// 新增:处理忽略应用的槽函数
|
||||
void MainWindow::onIgnoreApp(const QString &packageName, const QString &version) {
|
||||
// 将应用添加到忽略配置中
|
||||
m_ignoreConfig->addIgnoredApp(packageName, version);
|
||||
|
||||
// 更新模型中应用的状态,而不是移除
|
||||
QJsonArray updatedApps;
|
||||
for (const auto &item : m_allApps) {
|
||||
QJsonObject obj = item.toObject();
|
||||
if (obj["package"].toString() == packageName) {
|
||||
obj["ignored"] = true; // 标记为忽略状态
|
||||
}
|
||||
updatedApps.append(obj);
|
||||
}
|
||||
m_allApps = updatedApps;
|
||||
|
||||
// 重新排序:正常应用在前,忽略应用在后
|
||||
checkUpdates();
|
||||
}
|
||||
|
||||
// 新增:处理取消忽略应用的槽函数
|
||||
void MainWindow::onUnignoreApp(const QString &packageName, const QString &version) {
|
||||
// 从忽略配置中移除应用
|
||||
m_ignoreConfig->removeIgnoredApp(packageName, version);
|
||||
|
||||
// 更新模型中应用的状态
|
||||
QJsonArray updatedApps;
|
||||
for (const auto &item : m_allApps) {
|
||||
QJsonObject obj = item.toObject();
|
||||
if (obj["package"].toString() == packageName) {
|
||||
obj["ignored"] = false; // 标记为非忽略状态
|
||||
}
|
||||
updatedApps.append(obj);
|
||||
}
|
||||
m_allApps = updatedApps;
|
||||
|
||||
// 重新排序:正常应用在前,忽略应用在后
|
||||
checkUpdates();
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
#ifndef MAINWINDOW_H
|
||||
#define MAINWINDOW_H
|
||||
|
||||
#include <QMainWindow>
|
||||
#include "aptssupdater.h"
|
||||
#include "applistmodel.h"
|
||||
#include "appdelegate.h"
|
||||
#include "ignoreconfig.h"
|
||||
#include <QListView>
|
||||
#include <QJsonArray> // 添加头文件
|
||||
#include <QScreen>
|
||||
QT_BEGIN_NAMESPACE
|
||||
namespace Ui {
|
||||
class MainWindow;
|
||||
}
|
||||
QT_END_NAMESPACE
|
||||
|
||||
class MainWindow : public QMainWindow
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
MainWindow(QWidget *parent = nullptr);
|
||||
~MainWindow();
|
||||
|
||||
protected:
|
||||
void closeEvent(QCloseEvent *event) override;
|
||||
|
||||
private:
|
||||
Ui::MainWindow *ui;
|
||||
void checkUpdates();
|
||||
void initStyle();
|
||||
void runAptssUpgrade();
|
||||
AppListModel *m_model;
|
||||
AppDelegate *m_delegate;
|
||||
IgnoreConfig *m_ignoreConfig; // 新增:忽略配置管理
|
||||
QListView *listView; // 声明 QListView 指针
|
||||
QJsonArray m_allApps; // 新增:保存所有应用数据
|
||||
void filterAppsByKeyword(const QString &keyword); // 新增:搜索过滤函数声明
|
||||
void updateButtonText(); // 新增:更新按钮文本
|
||||
|
||||
private slots:
|
||||
void handleUpdateFinished(bool success); // 新增:处理更新完成的槽函数
|
||||
void handleSelectionChanged(); // 新增:处理选择变化的槽函数
|
||||
void onIgnoreApp(const QString &packageName, const QString &version); // 新增:处理忽略应用的槽函数
|
||||
void onUnignoreApp(const QString &packageName, const QString &version); // 新增:处理取消忽略应用
|
||||
};
|
||||
#endif // MAINWINDOW_H
|
||||
@@ -0,0 +1,256 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>MainWindow</class>
|
||||
<widget class="QMainWindow" name="MainWindow">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>800</width>
|
||||
<height>600</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>MainWindow</string>
|
||||
</property>
|
||||
<widget class="QWidget" name="centralwidget">
|
||||
<layout class="QGridLayout" name="gridLayout">
|
||||
<item row="0" column="0">
|
||||
<widget class="QWidget" name="titleWidget" native="true">
|
||||
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||
<property name="spacing">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="leftMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="font">
|
||||
<font>
|
||||
<family>Noto Sans Vai</family>
|
||||
<pointsize>22</pointsize>
|
||||
</font>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>软件更新中心</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="horizontalSpacer">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>1245</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QWidget" name="backgroundWidget" native="true">
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<widget class="QWidget" name="widget_4" native="true">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_2">
|
||||
<property name="spacing">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="leftMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QWidget" name="widget_3" native="true">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>320</width>
|
||||
<height>38</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>320</width>
|
||||
<height>38</height>
|
||||
</size>
|
||||
</property>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_3">
|
||||
<property name="spacing">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="leftMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QPlainTextEdit" name="searchPlainTextEdit">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>200</width>
|
||||
<height>38</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="plainText">
|
||||
<string/>
|
||||
</property>
|
||||
<property name="tabStopDistance">
|
||||
<double>81.000000000000000</double>
|
||||
</property>
|
||||
<property name="placeholderText">
|
||||
<string>搜索软件...</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="horizontalSpacer_2">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QWidget" name="widget" native="true">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>214</width>
|
||||
<height>40</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>214</width>
|
||||
<height>40</height>
|
||||
</size>
|
||||
</property>
|
||||
<widget class="QComboBox" name="FilterComboBox">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>102</width>
|
||||
<height>38</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>102</width>
|
||||
<height>38</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>102</width>
|
||||
<height>38</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="font">
|
||||
<font/>
|
||||
</property>
|
||||
<property name="layoutDirection">
|
||||
<enum>Qt::LayoutDirection::LeftToRight</enum>
|
||||
</property>
|
||||
<property name="sizeAdjustPolicy">
|
||||
<enum>QComboBox::SizeAdjustPolicy::AdjustToContentsOnFirstShow</enum>
|
||||
</property>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>按名称</string>
|
||||
</property>
|
||||
</item>
|
||||
</widget>
|
||||
<widget class="QPushButton" name="updatePushButton">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>118</x>
|
||||
<y>0</y>
|
||||
<width>96</width>
|
||||
<height>40</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>更新全部</string>
|
||||
</property>
|
||||
</widget>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QWidget" name="appWidget" native="true">
|
||||
<property name="enabled">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<widget class="QMenuBar" name="menubar">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>1440</width>
|
||||
<height>23</height>
|
||||
</rect>
|
||||
</property>
|
||||
</widget>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
+179
-1802
File diff suppressed because it is too large
Load Diff
@@ -20,13 +20,11 @@ Object.defineProperty(window, "ipcRenderer", {
|
||||
invoke: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
|
||||
// Mock window.apm_store
|
||||
Object.defineProperty(window, "apm_store", {
|
||||
value: {
|
||||
arch: "amd64",
|
||||
arch: "amd64-store",
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,452 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,212 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,193 +0,0 @@
|
||||
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" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,113 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,116 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -8,15 +8,13 @@ const renderSidebar = (
|
||||
) => {
|
||||
return render(AppSidebar, {
|
||||
props: {
|
||||
activeTab: "all",
|
||||
categories: {},
|
||||
activeCategory: "all",
|
||||
categoryCounts: { all: 0 },
|
||||
themeMode: "auto",
|
||||
storeFilter: "both",
|
||||
sparkAvailable: true,
|
||||
apmAvailable: true,
|
||||
sidebarEntries: [],
|
||||
entryCounts: {},
|
||||
currentUser: null,
|
||||
...overrides,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
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;");
|
||||
});
|
||||
});
|
||||
@@ -1,156 +0,0 @@
|
||||
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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,138 +0,0 @@
|
||||
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]);
|
||||
});
|
||||
});
|
||||
@@ -37,9 +37,6 @@ describe("InstalledAppsModal", () => {
|
||||
storeFilter: "both",
|
||||
sparkAvailable: true,
|
||||
apmAvailable: true,
|
||||
loggedIn: false,
|
||||
syncing: false,
|
||||
syncMessage: "",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -60,9 +57,6 @@ describe("InstalledAppsModal", () => {
|
||||
storeFilter: "both",
|
||||
sparkAvailable: true,
|
||||
apmAvailable: true,
|
||||
loggedIn: false,
|
||||
syncing: false,
|
||||
syncMessage: "",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -81,9 +75,6 @@ describe("InstalledAppsModal", () => {
|
||||
storeFilter: "both",
|
||||
sparkAvailable: true,
|
||||
apmAvailable: true,
|
||||
loggedIn: false,
|
||||
syncing: false,
|
||||
syncMessage: "",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -106,9 +97,6 @@ describe("InstalledAppsModal", () => {
|
||||
storeFilter: "both",
|
||||
sparkAvailable: true,
|
||||
apmAvailable: true,
|
||||
loggedIn: false,
|
||||
syncing: false,
|
||||
syncMessage: "",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -131,9 +119,6 @@ describe("InstalledAppsModal", () => {
|
||||
storeFilter: "both",
|
||||
sparkAvailable: true,
|
||||
apmAvailable: true,
|
||||
loggedIn: false,
|
||||
syncing: false,
|
||||
syncMessage: "",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -151,99 +136,9 @@ describe("InstalledAppsModal", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user