From 66aa798897f8c70399c8690dfcfc727ecce19790 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=B8=85=E5=B8=85?= <13546777571@163.com> Date: Sat, 25 Apr 2026 10:21:19 +0000 Subject: [PATCH 1/5] add LICENSE. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 李帅帅 <13546777571@163.com> --- LICENSE | 201 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..29f81d8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. -- Gitee From 449dd8114743e4b07332165264ea3a46997c627c Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Sat, 25 Apr 2026 17:32:29 +0800 Subject: [PATCH 2/5] Add ocstore Qt GUI and noVNC container setup --- .gitignore | 2 + ocstore/Dockerfile | 49 +++++ ocstore/README.md | 197 ++++++++++++++++++ ocstore/app.py | 412 +++++++++++++++++++++++++++++++++++++ ocstore/docker-compose.yml | 33 +++ ocstore/entrypoint.sh | 37 ++++ ocstore/requirements.txt | 1 + 7 files changed, 731 insertions(+) create mode 100644 .gitignore create mode 100644 ocstore/Dockerfile create mode 100644 ocstore/README.md create mode 100644 ocstore/app.py create mode 100644 ocstore/docker-compose.yml create mode 100755 ocstore/entrypoint.sh create mode 100644 ocstore/requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a60b85 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/ocstore/Dockerfile b/ocstore/Dockerfile new file mode 100644 index 0000000..68d6d9e --- /dev/null +++ b/ocstore/Dockerfile @@ -0,0 +1,49 @@ +FROM opencloudos/opencloudos:9 + +ENV DEBIAN_FRONTEND=noninteractive \ + APP_HOME=/opt/ocstore \ + PYTHONUNBUFFERED=1 + +RUN dnf install -y \ + python3 \ + python3-pip \ + python3-devel \ + gcc \ + gcc-c++ \ + make \ + xorg-x11-server-Xvfb \ + x11vnc \ + fluxbox \ + git \ + wget \ + which \ + procps-ng \ + dbus-daemon \ + flatpak \ + dnf-plugins-core \ + glib2 \ + libX11 \ + libxcb \ + libxkbcommon \ + mesa-libGL \ + mesa-libEGL \ + polkit \ + && dnf clean all + +RUN python3 -m pip install --no-cache-dir --upgrade pip + +WORKDIR ${APP_HOME} +COPY requirements.txt ${APP_HOME}/requirements.txt +RUN python3 -m pip install --no-cache-dir -r ${APP_HOME}/requirements.txt + +COPY app.py ${APP_HOME}/app.py +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +RUN git clone --depth=1 https://github.com/novnc/noVNC.git /opt/noVNC \ + && git clone --depth=1 https://github.com/novnc/websockify.git /opt/noVNC/utils/websockify + +RUN flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo || true + +EXPOSE 8080 5901 +ENTRYPOINT ["/entrypoint.sh"] diff --git a/ocstore/README.md b/ocstore/README.md new file mode 100644 index 0000000..923ed73 --- /dev/null +++ b/ocstore/README.md @@ -0,0 +1,197 @@ +# OpenCloudOS 图形化应用商店(ocstore) + +## 1. 技术栈与架构设计 + +### GUI 技术栈 +- **Python + PySide6(Qt for Python)** +- 选择原因:开发速度快、易于容器化、Qt 桌面控件成熟,适合快速交付图形化原型与后续扩展。 + +### 包管理交互方式 +- **RPM**:通过 `dnf` / `rpm` 命令查询仓库、已安装状态与执行安装/升级/卸载。 +- **Flatpak**:通过 `flatpak remotes`、`flatpak remote-ls`、`flatpak list`、`flatpak install/update/uninstall` 完成管理。 +- 当前实现通过 Python `subprocess.run()` 封装命令调用。 +- GUI 层与包管理逻辑分离: + - `app.py` 中 `PackageBackend` 负责命令调用与数据归一化。 + - Qt `MainWindow` 负责搜索、筛选、详情展示、异常弹窗与日志显示。 + +### 架构分层 +- **展示层**:Qt Widgets 主界面 +- **业务层**:`PackageBackend` +- **执行层**:系统命令 `dnf/rpm/flatpak` +- **远程访问层**:`Xvfb + fluxbox + x11vnc + noVNC` + +### 关键特性对应需求 +- 读取系统 RPM 仓库:`dnf repolist --enabled` +- Flatpak 远程仓库:默认接入 `flathub` +- 明确来源标识:列表使用 `[RPM]` / `[Flatpak]` +- 同名多来源:作为两个独立条目展示,用户可分别选择 +- 搜索 / 分类 / 详情页:已提供基础能力 +- 异常处理:命令失败时在 GUI 弹窗与日志面板提示 + +> 说明:这是一个可运行的 GUI 骨架与业务原型,适合作为 OpenCloudOS 应用商店前端雏形。若要进入生产环境,建议继续补充图标、分页、截图、权限代理、事务进度条、软件元数据缓存与 AppStream 集成。 + +--- + +## 2. 目录结构 + +```text +ocstore/ +├── app.py +├── Dockerfile +├── docker-compose.yml +├── entrypoint.sh +├── requirements.txt +└── README.md +``` + +--- + +## 3. 核心代码说明 + +### 主界面能力 +- 搜索 RPM / Flatpak 应用 +- 分类筛选(基础规则推断) +- 来源筛选 +- 详情页展示:名称、来源、版本、安装状态、仓库/远端、描述 +- 动作按钮:安装 / 卸载 / 更新 +- 仓库状态面板:显示 RPM 仓库与 Flatpak 远端 +- 日志面板:显示执行输出与错误 + +### 权限与异常说明 +- RPM 安装/升级/卸载默认使用 `pkexec dnf ...` +- Flatpak 直接调用 `flatpak ...` +- 若容器内未正确配置 polkit 或宿主权限,GUI 会显示错误提示 +- 网络异常、远端源不可用、仓库配置错误等,均会通过 stderr 捕获后在界面展示 + +--- + +## 4. 容器化部署清单 + +### Dockerfile +见 `Dockerfile`。 + +### docker-compose.yml +见 `docker-compose.yml`。 + +### 启动脚本 +见 `entrypoint.sh`。 + +noVNC 暴露地址: +- `http://<服务器IP>:8088/vnc.html` + +如果你的服务器公网地址是题目中的 `49.232.108.141`,则访问: +- `http://49.232.108.141:8088/vnc.html` + +--- + +## 5. 部署命令 + +在服务器上执行: + +```bash +cd ocstore +sudo docker compose build +sudo docker compose up -d +sudo docker compose logs -f +``` + +如果是旧版 docker-compose: + +```bash +cd ocstore +sudo docker-compose build +sudo docker-compose up -d +sudo docker-compose logs -f +``` + +查看容器状态: + +```bash +sudo docker ps +``` + +浏览器访问: + +```text +http://49.232.108.141:8088/vnc.html +``` + +--- + +## 6. 手动测试用例 + +### 用例 1:GUI 是否成功启动 +1. 启动 compose。 +2. 浏览器打开 `http://49.232.108.141:8088/vnc.html`。 +3. 检查是否能看到 OpenCloudOS 应用商店主窗口。 +4. 检查左侧是否显示 RPM 仓库与 Flatpak 远端信息。 + +### 用例 2:RPM 搜索功能 +1. 在搜索框输入 `firefox`。 +2. 点击“搜索”。 +3. 应看到 RPM 来源结果(前提是宿主仓库中有该包)。 +4. 点击条目后右侧详情区应更新。 + +### 用例 3:Flatpak 搜索功能 +1. 在搜索框输入 `org.mozilla.firefox` 或 `firefox`。 +2. 结果中应出现 `[Flatpak]` 来源条目(前提是 flathub 可访问)。 +3. 详情区显示远端来源通常为 `flathub`。 + +### 用例 4:RPM 安装测试 +1. 搜索一个未安装且体积较小的软件包,例如 `htop`。 +2. 选中 `[RPM] htop`。 +3. 点击“安装”。 +4. 观察日志区输出。 +5. 成功后状态应变为“已安装”。 + +验证: + +```bash +sudo docker exec -it ocstore rpm -qa | grep htop +``` + +### 用例 5:Flatpak 安装测试 +1. 搜索一个 Flatpak 应用,例如 `org.gnome.Calculator`。 +2. 点击“安装”。 +3. 等待日志输出成功。 + +验证: + +```bash +sudo docker exec -it ocstore flatpak list | grep org.gnome.Calculator +``` + +### 用例 6:卸载测试 +1. 选中一个已安装软件。 +2. 点击“卸载”。 +3. 观察日志区和状态变化。 + +### 用例 7:异常场景测试 +- 断开网络后搜索 Flatpak 应用,应有错误日志或空结果。 +- 移除 `/etc/yum.repos.d` 挂载后,RPM 仓库列表应异常。 +- 在无权限或无 polkit 环境下执行 RPM 安装,应弹出错误提示。 + +--- + +## 7. 生产化建议 + +为了真正达到发行版应用商店级别,建议后续继续增强: + +1. **使用 AppStream / PackageKit** 代替纯命令行解析,获得更稳定的软件元数据。 +2. **增加图标、截图、评分与详情缓存**。 +3. **引入后台任务队列**,展示下载/安装进度。 +4. **增加事务锁处理**,避免与系统其他 dnf 进程冲突。 +5. **细化权限代理**,将 RPM 操作封装为后端服务而不是直接在 GUI 中 `pkexec`。 +6. **区分宿主与容器包管理边界**:当前 compose 方案更适合作为演示和前端联调环境。若目标是管理宿主机真实软件,建议改成: + - GUI 在容器中运行 + - 包管理后端作为宿主机 systemd 服务暴露本地 API + - GUI 通过 Unix socket / localhost API 调用宿主能力 + +--- + +## 8. 已知限制 + +- Gitee Issue 页面公开抓取内容有限,本实现依据题述需求完成,不依赖页面隐藏细节。 +- 容器内直接操作 RPM/Flatpak 更偏向演示环境;生产环境建议将 GUI 与宿主机包管理解耦。 +- 当前分类逻辑为关键字推断,不是完整的软件中心元数据分类。 +- noVNC 默认未加鉴权,公网暴露时务必配合安全组、反向代理认证或 VPN。 diff --git a/ocstore/app.py b/ocstore/app.py new file mode 100644 index 0000000..8586e2c --- /dev/null +++ b/ocstore/app.py @@ -0,0 +1,412 @@ +#!/usr/bin/env python3 +import json +import os +import shlex +import subprocess +import sys +from dataclasses import dataclass, field +from typing import Dict, List, Optional + +from PySide6.QtCore import Qt, QThread, Signal +from PySide6.QtGui import QAction +from PySide6.QtWidgets import ( + QApplication, + QComboBox, + QFormLayout, + QGroupBox, + QHBoxLayout, + QLabel, + QLineEdit, + QListWidget, + QListWidgetItem, + QMainWindow, + QMessageBox, + QPushButton, + QPlainTextEdit, + QSplitter, + QStatusBar, + QTextEdit, + QToolBar, + QVBoxLayout, + QWidget, +) + + +@dataclass +class PackageRecord: + name: str + source: str + package_id: str + summary: str = "" + description: str = "" + version: str = "" + category: str = "Other" + installed: bool = False + repo: str = "" + remote: str = "" + raw: Dict = field(default_factory=dict) + + def label(self) -> str: + flag = "已安装" if self.installed else "未安装" + version = f" · {self.version}" if self.version else "" + return f"[{self.source}] {self.name}{version} · {flag}" + + +class CommandError(RuntimeError): + pass + + +class PackageBackend: + def __init__(self) -> None: + self.default_categories = { + "editor": "Development", + "browser": "Internet", + "office": "Office", + "media": "Multimedia", + "game": "Games", + "utility": "Utilities", + } + + def _run(self, cmd: List[str], check: bool = True) -> subprocess.CompletedProcess: + env = os.environ.copy() + env.setdefault("LANG", "C.UTF-8") + proc = subprocess.run(cmd, capture_output=True, text=True, env=env) + if check and proc.returncode != 0: + raise CommandError(proc.stderr.strip() or proc.stdout.strip() or "命令执行失败") + return proc + + def list_rpm_repos(self) -> List[str]: + try: + proc = self._run(["dnf", "repolist", "--enabled"], check=False) + return [line.strip() for line in proc.stdout.splitlines() if line.strip()] + except FileNotFoundError: + return ["dnf 不可用"] + + def list_flatpak_remotes(self) -> List[str]: + try: + proc = self._run(["flatpak", "remotes", "--columns=name,url"], check=False) + return [line.strip() for line in proc.stdout.splitlines() if line.strip()] + except FileNotFoundError: + return ["flatpak 不可用"] + + def _guess_category(self, text: str) -> str: + lowered = (text or "").lower() + for key, value in self.default_categories.items(): + if key in lowered: + return value + return "Other" + + def search_rpm(self, keyword: str) -> List[PackageRecord]: + try: + proc = self._run(["dnf", "repoquery", "--qf", "%{name}\t%{version}\t%{reponame}\t%{summary}", "--available", f"*{keyword}*"], check=False) + except FileNotFoundError: + return [] + records: List[PackageRecord] = [] + installed = self._installed_rpm_names() + for line in proc.stdout.splitlines(): + parts = line.split("\t") + if len(parts) < 4: + continue + name, version, repo, summary = parts[:4] + records.append( + PackageRecord( + name=name, + source="RPM", + package_id=name, + version=version, + repo=repo, + summary=summary, + description=summary, + category=self._guess_category(summary), + installed=name in installed, + raw={"repo": repo}, + ) + ) + return records + + def _installed_rpm_names(self) -> set: + try: + proc = self._run(["rpm", "-qa", "--qf", "%{NAME}\n"], check=False) + return {line.strip() for line in proc.stdout.splitlines() if line.strip()} + except FileNotFoundError: + return set() + + def search_flatpak(self, keyword: str) -> List[PackageRecord]: + try: + proc = self._run( + ["flatpak", "remote-ls", "--app", "--columns=name,application,version,description,origin", "flathub"], + check=False, + ) + except FileNotFoundError: + return [] + installed = self._installed_flatpak_ids() + records: List[PackageRecord] = [] + for line in proc.stdout.splitlines(): + parts = [p.strip() for p in line.split("\t")] + if len(parts) < 5: + continue + name, app_id, version, description, origin = parts[:5] + haystack = " ".join(parts).lower() + if keyword.lower() not in haystack: + continue + records.append( + PackageRecord( + name=name or app_id, + source="Flatpak", + package_id=app_id, + version=version, + remote=origin, + summary=description, + description=description, + category=self._guess_category(description), + installed=app_id in installed, + raw={"remote": origin}, + ) + ) + return records + + def _installed_flatpak_ids(self) -> set: + try: + proc = self._run(["flatpak", "list", "--app", "--columns=application"], check=False) + return {line.strip() for line in proc.stdout.splitlines() if line.strip()} + except FileNotFoundError: + return set() + + def search(self, keyword: str) -> List[PackageRecord]: + keyword = keyword.strip() + if not keyword: + keyword = "a" + merged = self.search_rpm(keyword) + self.search_flatpak(keyword) + merged.sort(key=lambda x: (x.name.lower(), x.source)) + return merged + + def install(self, pkg: PackageRecord) -> str: + if pkg.source == "RPM": + cmd = ["pkexec", "dnf", "install", "-y", pkg.package_id] + else: + remote = pkg.remote or "flathub" + cmd = ["flatpak", "install", "-y", remote, pkg.package_id] + return self._run(cmd).stdout or f"安装完成: {' '.join(cmd)}" + + def uninstall(self, pkg: PackageRecord) -> str: + if pkg.source == "RPM": + cmd = ["pkexec", "dnf", "remove", "-y", pkg.package_id] + else: + cmd = ["flatpak", "uninstall", "-y", pkg.package_id] + return self._run(cmd).stdout or f"卸载完成: {' '.join(cmd)}" + + def update(self, pkg: PackageRecord) -> str: + if pkg.source == "RPM": + cmd = ["pkexec", "dnf", "upgrade", "-y", pkg.package_id] + else: + cmd = ["flatpak", "update", "-y", pkg.package_id] + return self._run(cmd).stdout or f"更新完成: {' '.join(cmd)}" + + +class Worker(QThread): + success = Signal(object) + failure = Signal(str) + + def __init__(self, fn, *args, **kwargs): + super().__init__() + self.fn = fn + self.args = args + self.kwargs = kwargs + + def run(self): + try: + result = self.fn(*self.args, **self.kwargs) + self.success.emit(result) + except Exception as exc: + self.failure.emit(str(exc)) + + +class MainWindow(QMainWindow): + def __init__(self) -> None: + super().__init__() + self.backend = PackageBackend() + self.records: List[PackageRecord] = [] + self.current_record: Optional[PackageRecord] = None + self.worker: Optional[Worker] = None + self.setWindowTitle("OpenCloudOS 应用商店") + self.resize(1280, 820) + self._init_ui() + self.refresh_sources() + self.run_search() + + def _init_ui(self) -> None: + toolbar = QToolBar("主工具栏") + self.addToolBar(toolbar) + refresh_action = QAction("刷新", self) + refresh_action.triggered.connect(self.run_search) + toolbar.addAction(refresh_action) + + root = QWidget() + root_layout = QVBoxLayout(root) + + control_row = QHBoxLayout() + self.search_input = QLineEdit() + self.search_input.setPlaceholderText("搜索 RPM / Flatpak 应用,例如 firefox, libreoffice") + self.search_input.returnPressed.connect(self.run_search) + self.category_combo = QComboBox() + self.category_combo.addItems(["All", "Development", "Internet", "Office", "Multimedia", "Games", "Utilities", "Other"]) + self.category_combo.currentTextChanged.connect(self.apply_filter) + self.source_combo = QComboBox() + self.source_combo.addItems(["All", "RPM", "Flatpak"]) + self.source_combo.currentTextChanged.connect(self.apply_filter) + self.search_btn = QPushButton("搜索") + self.search_btn.clicked.connect(self.run_search) + control_row.addWidget(self.search_input, 4) + control_row.addWidget(self.category_combo, 1) + control_row.addWidget(self.source_combo, 1) + control_row.addWidget(self.search_btn) + root_layout.addLayout(control_row) + + splitter = QSplitter(Qt.Horizontal) + + left_panel = QWidget() + left_layout = QVBoxLayout(left_panel) + self.repo_info = QPlainTextEdit() + self.repo_info.setReadOnly(True) + self.repo_info.setMaximumHeight(120) + self.app_list = QListWidget() + self.app_list.currentItemChanged.connect(self.on_item_changed) + left_layout.addWidget(QLabel("仓库状态")) + left_layout.addWidget(self.repo_info) + left_layout.addWidget(QLabel("应用列表")) + left_layout.addWidget(self.app_list) + + right_panel = QWidget() + right_layout = QVBoxLayout(right_panel) + + detail_box = QGroupBox("应用详情") + form = QFormLayout(detail_box) + self.name_label = QLabel("-") + self.source_label = QLabel("-") + self.version_label = QLabel("-") + self.status_label = QLabel("-") + self.repo_label = QLabel("-") + self.summary_label = QTextEdit() + self.summary_label.setReadOnly(True) + self.summary_label.setMinimumHeight(220) + form.addRow("名称", self.name_label) + form.addRow("来源", self.source_label) + form.addRow("版本", self.version_label) + form.addRow("安装状态", self.status_label) + form.addRow("仓库/远端", self.repo_label) + form.addRow("描述", self.summary_label) + right_layout.addWidget(detail_box) + + button_row = QHBoxLayout() + self.install_btn = QPushButton("安装") + self.install_btn.clicked.connect(lambda: self.run_pkg_action("install")) + self.remove_btn = QPushButton("卸载") + self.remove_btn.clicked.connect(lambda: self.run_pkg_action("uninstall")) + self.update_btn = QPushButton("更新") + self.update_btn.clicked.connect(lambda: self.run_pkg_action("update")) + button_row.addWidget(self.install_btn) + button_row.addWidget(self.remove_btn) + button_row.addWidget(self.update_btn) + right_layout.addLayout(button_row) + + self.log = QPlainTextEdit() + self.log.setReadOnly(True) + right_layout.addWidget(QLabel("操作日志")) + right_layout.addWidget(self.log) + + splitter.addWidget(left_panel) + splitter.addWidget(right_panel) + splitter.setSizes([500, 780]) + root_layout.addWidget(splitter) + + self.setCentralWidget(root) + self.setStatusBar(QStatusBar()) + + def refresh_sources(self) -> None: + repo_lines = ["[RPM 仓库]"] + self.backend.list_rpm_repos() + ["", "[Flatpak 远端]"] + self.backend.list_flatpak_remotes() + self.repo_info.setPlainText("\n".join(repo_lines)) + + def run_search(self) -> None: + keyword = self.search_input.text().strip() + self.statusBar().showMessage("正在搜索应用...") + self.search_btn.setEnabled(False) + self.worker = Worker(self.backend.search, keyword) + self.worker.success.connect(self._on_search_done) + self.worker.failure.connect(self._on_worker_failed) + self.worker.start() + + def _on_search_done(self, records: List[PackageRecord]) -> None: + self.records = records + self.search_btn.setEnabled(True) + self.apply_filter() + self.statusBar().showMessage(f"找到 {len(records)} 个候选应用", 4000) + + def apply_filter(self) -> None: + self.app_list.clear() + category = self.category_combo.currentText() + source = self.source_combo.currentText() + for record in self.records: + if category != "All" and record.category != category: + continue + if source != "All" and record.source != source: + continue + item = QListWidgetItem(record.label()) + item.setData(Qt.UserRole, record) + self.app_list.addItem(item) + if self.app_list.count() > 0: + self.app_list.setCurrentRow(0) + + def on_item_changed(self, current: QListWidgetItem) -> None: + if not current: + return + record = current.data(Qt.UserRole) + self.current_record = record + self.name_label.setText(record.name) + self.source_label.setText(record.source) + self.version_label.setText(record.version or "未知") + self.status_label.setText("已安装" if record.installed else "未安装") + self.repo_label.setText(record.repo or record.remote or "-") + self.summary_label.setPlainText(record.description or record.summary or "暂无描述") + self.install_btn.setEnabled(not record.installed) + self.remove_btn.setEnabled(record.installed) + self.update_btn.setEnabled(record.installed) + + def run_pkg_action(self, action: str) -> None: + if not self.current_record: + QMessageBox.warning(self, "未选择应用", "请先选择一个应用。") + return + pkg = self.current_record + fn = getattr(self.backend, action) + self.log.appendPlainText(f"$ {action} {pkg.package_id} [{pkg.source}]") + self.install_btn.setEnabled(False) + self.remove_btn.setEnabled(False) + self.update_btn.setEnabled(False) + self.worker = Worker(fn, pkg) + self.worker.success.connect(lambda output: self._on_pkg_action_done(output, action)) + self.worker.failure.connect(self._on_worker_failed) + self.worker.start() + + def _on_pkg_action_done(self, output: str, action: str) -> None: + self.log.appendPlainText(output.strip() or f"{action} 完成") + self.refresh_sources() + self.run_search() + QMessageBox.information(self, "操作完成", f"{action} 操作已完成。") + + def _on_worker_failed(self, message: str) -> None: + self.search_btn.setEnabled(True) + self.log.appendPlainText(f"[ERROR] {message}") + self.statusBar().showMessage("操作失败", 4000) + QMessageBox.critical(self, "操作失败", message) + if self.current_record: + self.on_item_changed(self.app_list.currentItem()) + + +def main() -> int: + app = QApplication(sys.argv) + window = MainWindow() + window.show() + return app.exec() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/ocstore/docker-compose.yml b/ocstore/docker-compose.yml new file mode 100644 index 0000000..156e353 --- /dev/null +++ b/ocstore/docker-compose.yml @@ -0,0 +1,33 @@ +version: "3.9" + +services: + ocstore: + build: + context: . + dockerfile: Dockerfile + container_name: ocstore + ports: + - "8088:8080" + - "5901:5901" + environment: + DISPLAY: ":1" + SCREEN_WIDTH: "1440" + SCREEN_HEIGHT: "900" + SCREEN_DEPTH: "24" + NOVNC_PORT: "8080" + VNC_PORT: "5901" + restart: unless-stopped + privileged: true + volumes: + - ocstore-flatpak:/var/lib/flatpak + - ocstore-cache:/var/cache/dnf + - /etc/yum.repos.d:/etc/yum.repos.d:ro + - /etc/pki:/etc/pki:ro + - /var/lib/rpm:/var/lib/rpm:rw + - /usr/bin/rpm:/usr/bin/rpm:ro + - /usr/bin/dnf:/usr/bin/dnf:ro + - /usr/bin/flatpak:/usr/bin/flatpak:ro + +volumes: + ocstore-flatpak: + ocstore-cache: diff --git a/ocstore/entrypoint.sh b/ocstore/entrypoint.sh new file mode 100755 index 0000000..653ee24 --- /dev/null +++ b/ocstore/entrypoint.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail + +export DISPLAY=${DISPLAY:-:1} +export VNC_PORT=${VNC_PORT:-5901} +export NOVNC_PORT=${NOVNC_PORT:-8080} +export SCREEN_WIDTH=${SCREEN_WIDTH:-1440} +export SCREEN_HEIGHT=${SCREEN_HEIGHT:-900} +export SCREEN_DEPTH=${SCREEN_DEPTH:-24} +export APP_HOME=${APP_HOME:-/opt/ocstore} +export QT_QPA_PLATFORM=${QT_QPA_PLATFORM:-xcb} + +mkdir -p /tmp/.X11-unix /var/log/ocstore +rm -f /tmp/.X1-lock || true + +Xvfb ${DISPLAY} -screen 0 ${SCREEN_WIDTH}x${SCREEN_HEIGHT}x${SCREEN_DEPTH} & +XVFB_PID=$! + +sleep 1 +fluxbox >/var/log/ocstore/fluxbox.log 2>&1 & +FLUXBOX_PID=$! + +x11vnc -display ${DISPLAY} -forever -shared -nopw -listen 0.0.0.0 -rfbport ${VNC_PORT} >/var/log/ocstore/x11vnc.log 2>&1 & +X11VNC_PID=$! + +/opt/noVNC/utils/novnc_proxy --vnc localhost:${VNC_PORT} --listen ${NOVNC_PORT} >/var/log/ocstore/novnc.log 2>&1 & +NOVNC_PID=$! + +python3 ${APP_HOME}/app.py >/var/log/ocstore/app.log 2>&1 & +APP_PID=$! + +echo "OpenCloudOS Store started" +echo " DISPLAY=${DISPLAY}" +echo " noVNC=http://0.0.0.0:${NOVNC_PORT}/vnc.html" + +trap 'kill ${APP_PID} ${NOVNC_PID} ${X11VNC_PID} ${FLUXBOX_PID} ${XVFB_PID} 2>/dev/null || true' SIGTERM SIGINT +wait -n ${APP_PID} ${NOVNC_PID} ${X11VNC_PID} ${FLUXBOX_PID} ${XVFB_PID} diff --git a/ocstore/requirements.txt b/ocstore/requirements.txt new file mode 100644 index 0000000..35619bf --- /dev/null +++ b/ocstore/requirements.txt @@ -0,0 +1 @@ +PySide6==6.9.0 -- Gitee From 84cba1a767bf93f9ff29f3b077a31b73d9ff642d Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Sat, 25 Apr 2026 17:38:22 +0800 Subject: [PATCH 3/5] Enhance ocstore UI with storefront layout --- ocstore/README.md | 6 +- ocstore/app.py | 402 +++++++++++++++++++++++++++++++++++++--------- 2 files changed, 329 insertions(+), 79 deletions(-) diff --git a/ocstore/README.md b/ocstore/README.md index 923ed73..2e65bd2 100644 --- a/ocstore/README.md +++ b/ocstore/README.md @@ -49,10 +49,12 @@ ocstore/ ## 3. 核心代码说明 ### 主界面能力 +- 商店化首页头图与统计卡片(候选应用 / RPM / Flatpak) - 搜索 RPM / Flatpak 应用 -- 分类筛选(基础规则推断) +- 左侧分类浏览(基础规则推断) - 来源筛选 -- 详情页展示:名称、来源、版本、安装状态、仓库/远端、描述 +- 卡片式应用列表,显式展示来源、版本、安装状态 +- 详情页展示:名称、来源、版本、安装状态、仓库/远端、描述、包标识 - 动作按钮:安装 / 卸载 / 更新 - 仓库状态面板:显示 RPM 仓库与 Flatpak 远端 - 日志面板:显示执行输出与错误 diff --git a/ocstore/app.py b/ocstore/app.py index 8586e2c..852ad79 100644 --- a/ocstore/app.py +++ b/ocstore/app.py @@ -1,18 +1,19 @@ #!/usr/bin/env python3 -import json import os -import shlex import subprocess import sys from dataclasses import dataclass, field from typing import Dict, List, Optional from PySide6.QtCore import Qt, QThread, Signal -from PySide6.QtGui import QAction +from PySide6.QtGui import QAction, QColor from PySide6.QtWidgets import ( QApplication, QComboBox, QFormLayout, + QFrame, + QGraphicsDropShadowEffect, + QGridLayout, QGroupBox, QHBoxLayout, QLabel, @@ -23,7 +24,8 @@ from PySide6.QtWidgets import ( QMessageBox, QPushButton, QPlainTextEdit, - QSplitter, + QScrollArea, + QSizePolicy, QStatusBar, QTextEdit, QToolBar, @@ -46,10 +48,11 @@ class PackageRecord: remote: str = "" raw: Dict = field(default_factory=dict) - def label(self) -> str: - flag = "已安装" if self.installed else "未安装" - version = f" · {self.version}" if self.version else "" - return f"[{self.source}] {self.name}{version} · {flag}" + def source_badge(self) -> str: + return "RPM" if self.source == "RPM" else "Flatpak" + + def state_text(self) -> str: + return "已安装" if self.installed else "可安装" class CommandError(RuntimeError): @@ -60,11 +63,18 @@ class PackageBackend: def __init__(self) -> None: self.default_categories = { "editor": "Development", + "ide": "Development", "browser": "Internet", + "web": "Internet", "office": "Office", - "media": "Multimedia", + "document": "Office", + "video": "Multimedia", + "music": "Multimedia", + "audio": "Multimedia", "game": "Games", "utility": "Utilities", + "tool": "Utilities", + "system": "Utilities", } def _run(self, cmd: List[str], check: bool = True) -> subprocess.CompletedProcess: @@ -98,7 +108,14 @@ class PackageBackend: def search_rpm(self, keyword: str) -> List[PackageRecord]: try: - proc = self._run(["dnf", "repoquery", "--qf", "%{name}\t%{version}\t%{reponame}\t%{summary}", "--available", f"*{keyword}*"], check=False) + proc = self._run([ + "dnf", + "repoquery", + "--qf", + "%{name}\t%{version}\t%{reponame}\t%{summary}", + "--available", + f"*{keyword}*", + ], check=False) except FileNotFoundError: return [] records: List[PackageRecord] = [] @@ -173,9 +190,7 @@ class PackageBackend: return set() def search(self, keyword: str) -> List[PackageRecord]: - keyword = keyword.strip() - if not keyword: - keyword = "a" + keyword = keyword.strip() or "a" merged = self.search_rpm(keyword) + self.search_flatpak(keyword) merged.sort(key=lambda x: (x.name.lower(), x.source)) return merged @@ -221,81 +236,254 @@ class Worker(QThread): self.failure.emit(str(exc)) +class AppCard(QFrame): + clicked = Signal(object) + + def __init__(self, record: PackageRecord): + super().__init__() + self.record = record + self.setObjectName("AppCard") + self.setCursor(Qt.PointingHandCursor) + self.setFrameShape(QFrame.StyledPanel) + self.setMinimumHeight(110) + shadow = QGraphicsDropShadowEffect(self) + shadow.setBlurRadius(18) + shadow.setOffset(0, 4) + shadow.setColor(QColor(0, 0, 0, 45)) + self.setGraphicsEffect(shadow) + + layout = QVBoxLayout(self) + top = QHBoxLayout() + title = QLabel(record.name) + title.setObjectName("CardTitle") + badge = QLabel(record.source_badge()) + badge.setProperty("badge", record.source) + badge.setObjectName("SourceBadge") + top.addWidget(title) + top.addStretch(1) + top.addWidget(badge) + + subtitle = QLabel(record.summary or "暂无简介") + subtitle.setObjectName("CardSubtitle") + subtitle.setWordWrap(True) + + bottom = QHBoxLayout() + state = QLabel(record.state_text()) + state.setObjectName("StateBadge") + version = QLabel(record.version or "未标注版本") + version.setObjectName("CardMeta") + category = QLabel(record.category) + category.setObjectName("CardMeta") + bottom.addWidget(state) + bottom.addWidget(version) + bottom.addWidget(category) + bottom.addStretch(1) + + layout.addLayout(top) + layout.addWidget(subtitle) + layout.addLayout(bottom) + + def mousePressEvent(self, event): + self.clicked.emit(self.record) + super().mousePressEvent(event) + + class MainWindow(QMainWindow): def __init__(self) -> None: super().__init__() self.backend = PackageBackend() self.records: List[PackageRecord] = [] + self.filtered_records: List[PackageRecord] = [] self.current_record: Optional[PackageRecord] = None self.worker: Optional[Worker] = None + self.category_buttons: Dict[str, QPushButton] = {} + self.active_category = "All" self.setWindowTitle("OpenCloudOS 应用商店") - self.resize(1280, 820) + self.resize(1400, 900) + self._apply_style() self._init_ui() self.refresh_sources() self.run_search() + def _apply_style(self) -> None: + self.setStyleSheet(""" + QMainWindow, QWidget { background: #f5f7fb; color: #1d2433; font-size: 14px; } + QToolBar { background: #ffffff; border: none; spacing: 8px; padding: 8px; } + QLineEdit, QComboBox, QTextEdit, QPlainTextEdit { + background: #ffffff; border: 1px solid #d9e1ef; border-radius: 10px; padding: 8px 10px; + } + QPushButton { + background: #2d6cff; color: white; border: none; border-radius: 10px; padding: 10px 16px; font-weight: 600; + } + QPushButton:hover { background: #1f5ef5; } + QPushButton:disabled { background: #b8c5e6; color: #eef2ff; } + QGroupBox { + background: #ffffff; border: 1px solid #e6ebf5; border-radius: 16px; margin-top: 12px; font-weight: 700; + } + QGroupBox::title { subcontrol-origin: margin; left: 14px; padding: 0 6px; } + #HeroCard, #SidePanel, #DetailPanel, #LogPanel { + background: #ffffff; border: 1px solid #e6ebf5; border-radius: 18px; + } + #AppCard { + background: #ffffff; border: 1px solid #e6ebf5; border-radius: 16px; padding: 6px; + } + #AppCard:hover { border: 1px solid #8fb1ff; } + #CardTitle { font-size: 18px; font-weight: 700; } + #CardSubtitle { color: #5f6b85; } + #CardMeta { color: #7a869f; } + #SourceBadge[badge="RPM"] { + background: #e8f1ff; color: #1f5ef5; border-radius: 10px; padding: 4px 10px; font-weight: 700; + } + #SourceBadge[badge="Flatpak"] { + background: #eef9ef; color: #16803c; border-radius: 10px; padding: 4px 10px; font-weight: 700; + } + #StateBadge { + background: #f3f6fc; color: #3d4a63; border-radius: 10px; padding: 4px 10px; font-weight: 600; + } + #HeroTitle { font-size: 28px; font-weight: 800; } + #HeroSubTitle { color: #5f6b85; font-size: 15px; } + #MetricValue { font-size: 24px; font-weight: 800; } + #MetricLabel { color: #6e7890; } + #CategoryButton { + background: #ffffff; color: #1d2433; text-align: left; border: 1px solid #e6ebf5; border-radius: 12px; padding: 10px 12px; + } + #CategoryButton[active="true"] { background: #2d6cff; color: white; border: none; } + """) + def _init_ui(self) -> None: toolbar = QToolBar("主工具栏") self.addToolBar(toolbar) - refresh_action = QAction("刷新", self) + refresh_action = QAction("刷新仓库与结果", self) refresh_action.triggered.connect(self.run_search) toolbar.addAction(refresh_action) root = QWidget() root_layout = QVBoxLayout(root) - - control_row = QHBoxLayout() + root_layout.setContentsMargins(20, 18, 20, 20) + root_layout.setSpacing(16) + + hero = QFrame() + hero.setObjectName("HeroCard") + hero_layout = QHBoxLayout(hero) + hero_layout.setContentsMargins(22, 20, 22, 20) + + hero_left = QVBoxLayout() + hero_title = QLabel("OpenCloudOS 图形化应用商店") + hero_title.setObjectName("HeroTitle") + hero_subtitle = QLabel("统一管理 RPM 与 Flatpak 应用,支持搜索、分类浏览、详情查看与安装更新。") + hero_subtitle.setObjectName("HeroSubTitle") + hero_subtitle.setWordWrap(True) + hero_left.addWidget(hero_title) + hero_left.addWidget(hero_subtitle) + + hero_right = QHBoxLayout() + self.metric_total = self._metric_card("0", "候选应用") + self.metric_rpm = self._metric_card("0", "RPM") + self.metric_flatpak = self._metric_card("0", "Flatpak") + hero_right.addWidget(self.metric_total) + hero_right.addWidget(self.metric_rpm) + hero_right.addWidget(self.metric_flatpak) + hero_layout.addLayout(hero_left, 3) + hero_layout.addLayout(hero_right, 2) + root_layout.addWidget(hero) + + search_row = QHBoxLayout() self.search_input = QLineEdit() - self.search_input.setPlaceholderText("搜索 RPM / Flatpak 应用,例如 firefox, libreoffice") + self.search_input.setPlaceholderText("搜索应用,例如 firefox、libreoffice、calculator") self.search_input.returnPressed.connect(self.run_search) - self.category_combo = QComboBox() - self.category_combo.addItems(["All", "Development", "Internet", "Office", "Multimedia", "Games", "Utilities", "Other"]) - self.category_combo.currentTextChanged.connect(self.apply_filter) self.source_combo = QComboBox() self.source_combo.addItems(["All", "RPM", "Flatpak"]) self.source_combo.currentTextChanged.connect(self.apply_filter) self.search_btn = QPushButton("搜索") self.search_btn.clicked.connect(self.run_search) - control_row.addWidget(self.search_input, 4) - control_row.addWidget(self.category_combo, 1) - control_row.addWidget(self.source_combo, 1) - control_row.addWidget(self.search_btn) - root_layout.addLayout(control_row) - - splitter = QSplitter(Qt.Horizontal) - - left_panel = QWidget() - left_layout = QVBoxLayout(left_panel) + search_row.addWidget(self.search_input, 5) + search_row.addWidget(self.source_combo, 1) + search_row.addWidget(self.search_btn) + root_layout.addLayout(search_row) + + content = QHBoxLayout() + content.setSpacing(16) + + side_panel = QFrame() + side_panel.setObjectName("SidePanel") + side_layout = QVBoxLayout(side_panel) + side_layout.setContentsMargins(16, 16, 16, 16) + side_layout.setSpacing(12) + side_layout.addWidget(QLabel("分类浏览")) + for category in ["All", "Development", "Internet", "Office", "Multimedia", "Games", "Utilities", "Other"]: + btn = QPushButton(category) + btn.setObjectName("CategoryButton") + btn.setProperty("active", "true" if category == "All" else "false") + btn.clicked.connect(lambda checked=False, c=category: self.set_category(c)) + btn.style().unpolish(btn) + btn.style().polish(btn) + self.category_buttons[category] = btn + side_layout.addWidget(btn) + side_layout.addSpacing(8) + side_layout.addWidget(QLabel("仓库状态")) self.repo_info = QPlainTextEdit() self.repo_info.setReadOnly(True) - self.repo_info.setMaximumHeight(120) - self.app_list = QListWidget() - self.app_list.currentItemChanged.connect(self.on_item_changed) - left_layout.addWidget(QLabel("仓库状态")) - left_layout.addWidget(self.repo_info) - left_layout.addWidget(QLabel("应用列表")) - left_layout.addWidget(self.app_list) - - right_panel = QWidget() - right_layout = QVBoxLayout(right_panel) - - detail_box = QGroupBox("应用详情") - form = QFormLayout(detail_box) - self.name_label = QLabel("-") - self.source_label = QLabel("-") + self.repo_info.setMaximumHeight(220) + side_layout.addWidget(self.repo_info) + side_layout.addStretch(1) + content.addWidget(side_panel, 1) + + center_right = QHBoxLayout() + center_right.setSpacing(16) + + self.card_scroll = QScrollArea() + self.card_scroll.setWidgetResizable(True) + self.card_scroll.setFrameShape(QFrame.NoFrame) + card_container = QWidget() + self.card_layout = QVBoxLayout(card_container) + self.card_layout.setContentsMargins(4, 4, 4, 4) + self.card_layout.setSpacing(14) + self.card_layout.addStretch(1) + self.card_scroll.setWidget(card_container) + center_right.addWidget(self.card_scroll, 2) + + detail_col = QVBoxLayout() + detail_col.setSpacing(16) + + detail_panel = QFrame() + detail_panel.setObjectName("DetailPanel") + detail_layout = QVBoxLayout(detail_panel) + detail_layout.setContentsMargins(18, 18, 18, 18) + detail_layout.setSpacing(12) + detail_layout.addWidget(QLabel("应用详情")) + + self.detail_name = QLabel("请选择一个应用") + self.detail_name.setObjectName("HeroTitle") + self.detail_name.setWordWrap(True) + self.detail_badge = QLabel("-") + self.detail_badge.setObjectName("SourceBadge") + self.detail_badge.setProperty("badge", "RPM") + self.detail_state = QLabel("-") + self.detail_state.setObjectName("StateBadge") + + top_badges = QHBoxLayout() + top_badges.addWidget(self.detail_badge) + top_badges.addWidget(self.detail_state) + top_badges.addStretch(1) + + detail_layout.addWidget(self.detail_name) + detail_layout.addLayout(top_badges) + + form_box = QGroupBox("详细信息") + form = QFormLayout(form_box) self.version_label = QLabel("-") - self.status_label = QLabel("-") self.repo_label = QLabel("-") + self.category_label = QLabel("-") + self.package_id_label = QLabel("-") self.summary_label = QTextEdit() self.summary_label.setReadOnly(True) self.summary_label.setMinimumHeight(220) - form.addRow("名称", self.name_label) - form.addRow("来源", self.source_label) form.addRow("版本", self.version_label) - form.addRow("安装状态", self.status_label) form.addRow("仓库/远端", self.repo_label) + form.addRow("分类", self.category_label) + form.addRow("包标识", self.package_id_label) form.addRow("描述", self.summary_label) - right_layout.addWidget(detail_box) + detail_layout.addWidget(form_box) button_row = QHBoxLayout() self.install_btn = QPushButton("安装") @@ -307,21 +495,42 @@ class MainWindow(QMainWindow): button_row.addWidget(self.install_btn) button_row.addWidget(self.remove_btn) button_row.addWidget(self.update_btn) - right_layout.addLayout(button_row) - + detail_layout.addLayout(button_row) + detail_col.addWidget(detail_panel) + + log_panel = QFrame() + log_panel.setObjectName("LogPanel") + log_layout = QVBoxLayout(log_panel) + log_layout.setContentsMargins(18, 18, 18, 18) + log_layout.addWidget(QLabel("操作日志")) self.log = QPlainTextEdit() self.log.setReadOnly(True) - right_layout.addWidget(QLabel("操作日志")) - right_layout.addWidget(self.log) + self.log.setMinimumHeight(180) + log_layout.addWidget(self.log) + detail_col.addWidget(log_panel) - splitter.addWidget(left_panel) - splitter.addWidget(right_panel) - splitter.setSizes([500, 780]) - root_layout.addWidget(splitter) + center_right.addLayout(detail_col, 2) + content.addLayout(center_right, 4) + root_layout.addLayout(content) self.setCentralWidget(root) self.setStatusBar(QStatusBar()) + def _metric_card(self, value: str, label: str) -> QWidget: + box = QFrame() + box.setObjectName("HeroCard") + box.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) + layout = QVBoxLayout(box) + layout.setContentsMargins(16, 14, 16, 14) + value_label = QLabel(value) + value_label.setObjectName("MetricValue") + text_label = QLabel(label) + text_label.setObjectName("MetricLabel") + layout.addWidget(value_label) + layout.addWidget(text_label) + box.value_label = value_label + return box + def refresh_sources(self) -> None: repo_lines = ["[RPM 仓库]"] + self.backend.list_rpm_repos() + ["", "[Flatpak 远端]"] + self.backend.list_flatpak_remotes() self.repo_info.setPlainText("\n".join(repo_lines)) @@ -338,34 +547,74 @@ class MainWindow(QMainWindow): def _on_search_done(self, records: List[PackageRecord]) -> None: self.records = records self.search_btn.setEnabled(True) + self.refresh_sources() + self._update_metrics(records) self.apply_filter() self.statusBar().showMessage(f"找到 {len(records)} 个候选应用", 4000) + def _update_metrics(self, records: List[PackageRecord]) -> None: + rpm_count = sum(1 for r in records if r.source == "RPM") + flatpak_count = sum(1 for r in records if r.source == "Flatpak") + self.metric_total.value_label.setText(str(len(records))) + self.metric_rpm.value_label.setText(str(rpm_count)) + self.metric_flatpak.value_label.setText(str(flatpak_count)) + + def set_category(self, category: str) -> None: + self.active_category = category + for name, btn in self.category_buttons.items(): + btn.setProperty("active", "true" if name == category else "false") + btn.style().unpolish(btn) + btn.style().polish(btn) + self.apply_filter() + + def clear_cards(self) -> None: + while self.card_layout.count(): + item = self.card_layout.takeAt(0) + widget = item.widget() + if widget: + widget.deleteLater() + def apply_filter(self) -> None: - self.app_list.clear() - category = self.category_combo.currentText() + self.clear_cards() source = self.source_combo.currentText() + self.filtered_records = [] for record in self.records: - if category != "All" and record.category != category: + if self.active_category != "All" and record.category != self.active_category: continue if source != "All" and record.source != source: continue - item = QListWidgetItem(record.label()) - item.setData(Qt.UserRole, record) - self.app_list.addItem(item) - if self.app_list.count() > 0: - self.app_list.setCurrentRow(0) - - def on_item_changed(self, current: QListWidgetItem) -> None: - if not current: - return - record = current.data(Qt.UserRole) + self.filtered_records.append(record) + card = AppCard(record) + card.clicked.connect(self.show_record) + self.card_layout.addWidget(card) + self.card_layout.addStretch(1) + if self.filtered_records: + self.show_record(self.filtered_records[0]) + else: + self.current_record = None + self.detail_name.setText("没有匹配结果") + self.summary_label.setPlainText("请尝试更换关键字、分类或来源筛选。") + self.version_label.setText("-") + self.repo_label.setText("-") + self.category_label.setText("-") + self.package_id_label.setText("-") + self.detail_state.setText("-") + self.install_btn.setEnabled(False) + self.remove_btn.setEnabled(False) + self.update_btn.setEnabled(False) + + def show_record(self, record: PackageRecord) -> None: self.current_record = record - self.name_label.setText(record.name) - self.source_label.setText(record.source) + self.detail_name.setText(record.name) + self.detail_badge.setText(record.source_badge()) + self.detail_badge.setProperty("badge", record.source) + self.detail_badge.style().unpolish(self.detail_badge) + self.detail_badge.style().polish(self.detail_badge) + self.detail_state.setText(record.state_text()) self.version_label.setText(record.version or "未知") - self.status_label.setText("已安装" if record.installed else "未安装") self.repo_label.setText(record.repo or record.remote or "-") + self.category_label.setText(record.category) + self.package_id_label.setText(record.package_id) self.summary_label.setPlainText(record.description or record.summary or "暂无描述") self.install_btn.setEnabled(not record.installed) self.remove_btn.setEnabled(record.installed) @@ -388,7 +637,6 @@ class MainWindow(QMainWindow): def _on_pkg_action_done(self, output: str, action: str) -> None: self.log.appendPlainText(output.strip() or f"{action} 完成") - self.refresh_sources() self.run_search() QMessageBox.information(self, "操作完成", f"{action} 操作已完成。") @@ -398,7 +646,7 @@ class MainWindow(QMainWindow): self.statusBar().showMessage("操作失败", 4000) QMessageBox.critical(self, "操作失败", message) if self.current_record: - self.on_item_changed(self.app_list.currentItem()) + self.show_record(self.current_record) def main() -> int: -- Gitee From b7c22384273d11904061e683b54f59fd2d97b7da Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Sat, 25 Apr 2026 17:41:47 +0800 Subject: [PATCH 4/5] Upgrade ocstore detail page and source switching --- ocstore/README.md | 3 + ocstore/app.py | 171 +++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 157 insertions(+), 17 deletions(-) diff --git a/ocstore/README.md b/ocstore/README.md index 2e65bd2..bdc0472 100644 --- a/ocstore/README.md +++ b/ocstore/README.md @@ -55,6 +55,9 @@ ocstore/ - 来源筛选 - 卡片式应用列表,显式展示来源、版本、安装状态 - 详情页展示:名称、来源、版本、安装状态、仓库/远端、描述、包标识 +- 详情头图/截图占位区,便于后续接入 AppStream 元数据 +- 同名多来源切换器(同名 RPM / Flatpak 间直接切换) +- 相关推荐区(按分类或来源推荐) - 动作按钮:安装 / 卸载 / 更新 - 仓库状态面板:显示 RPM 仓库与 Flatpak 远端 - 日志面板:显示执行输出与错误 diff --git a/ocstore/app.py b/ocstore/app.py index 852ad79..c0ea9b3 100644 --- a/ocstore/app.py +++ b/ocstore/app.py @@ -2,6 +2,7 @@ import os import subprocess import sys +from collections import defaultdict from dataclasses import dataclass, field from typing import Dict, List, Optional @@ -13,13 +14,10 @@ from PySide6.QtWidgets import ( QFormLayout, QFrame, QGraphicsDropShadowEffect, - QGridLayout, QGroupBox, QHBoxLayout, QLabel, QLineEdit, - QListWidget, - QListWidgetItem, QMainWindow, QMessageBox, QPushButton, @@ -245,14 +243,23 @@ class AppCard(QFrame): self.setObjectName("AppCard") self.setCursor(Qt.PointingHandCursor) self.setFrameShape(QFrame.StyledPanel) - self.setMinimumHeight(110) + self.setMinimumHeight(118) shadow = QGraphicsDropShadowEffect(self) shadow.setBlurRadius(18) shadow.setOffset(0, 4) shadow.setColor(QColor(0, 0, 0, 45)) self.setGraphicsEffect(shadow) - layout = QVBoxLayout(self) + layout = QHBoxLayout(self) + layout.setContentsMargins(14, 14, 14, 14) + layout.setSpacing(12) + + icon = QLabel(record.name[:1].upper()) + icon.setObjectName("AppIcon") + icon.setAlignment(Qt.AlignCenter) + icon.setFixedSize(54, 54) + + right = QVBoxLayout() top = QHBoxLayout() title = QLabel(record.name) title.setObjectName("CardTitle") @@ -279,9 +286,50 @@ class AppCard(QFrame): bottom.addWidget(category) bottom.addStretch(1) - layout.addLayout(top) - layout.addWidget(subtitle) - layout.addLayout(bottom) + right.addLayout(top) + right.addWidget(subtitle) + right.addLayout(bottom) + layout.addWidget(icon) + layout.addLayout(right, 1) + + def mousePressEvent(self, event): + self.clicked.emit(self.record) + super().mousePressEvent(event) + + +class SourceChip(QPushButton): + selected = Signal(object) + + def __init__(self, record: PackageRecord): + super().__init__(f"{record.source_badge()} · {record.version or '未知版本'}") + self.record = record + self.setObjectName("SourceChip") + self.clicked.connect(lambda: self.selected.emit(record)) + + +class RecommendationCard(QFrame): + clicked = Signal(object) + + def __init__(self, record: PackageRecord): + super().__init__() + self.record = record + self.setObjectName("RecommendationCard") + self.setCursor(Qt.PointingHandCursor) + layout = QVBoxLayout(self) + layout.setContentsMargins(12, 12, 12, 12) + icon = QLabel(record.name[:1].upper()) + icon.setObjectName("MiniAppIcon") + icon.setAlignment(Qt.AlignCenter) + icon.setFixedSize(40, 40) + title = QLabel(record.name) + title.setObjectName("RecommendationTitle") + desc = QLabel(record.summary or record.category) + desc.setObjectName("CardMeta") + desc.setWordWrap(True) + layout.addWidget(icon) + layout.addWidget(title) + layout.addWidget(desc) + layout.addStretch(1) def mousePressEvent(self, event): self.clicked.emit(self.record) @@ -294,12 +342,13 @@ class MainWindow(QMainWindow): self.backend = PackageBackend() self.records: List[PackageRecord] = [] self.filtered_records: List[PackageRecord] = [] + self.records_by_name: Dict[str, List[PackageRecord]] = defaultdict(list) self.current_record: Optional[PackageRecord] = None self.worker: Optional[Worker] = None self.category_buttons: Dict[str, QPushButton] = {} self.active_category = "All" self.setWindowTitle("OpenCloudOS 应用商店") - self.resize(1400, 900) + self.resize(1480, 920) self._apply_style() self._init_ui() self.refresh_sources() @@ -321,13 +370,13 @@ class MainWindow(QMainWindow): background: #ffffff; border: 1px solid #e6ebf5; border-radius: 16px; margin-top: 12px; font-weight: 700; } QGroupBox::title { subcontrol-origin: margin; left: 14px; padding: 0 6px; } - #HeroCard, #SidePanel, #DetailPanel, #LogPanel { + #HeroCard, #SidePanel, #DetailPanel, #LogPanel, #PreviewPanel, #RelationPanel { background: #ffffff; border: 1px solid #e6ebf5; border-radius: 18px; } - #AppCard { - background: #ffffff; border: 1px solid #e6ebf5; border-radius: 16px; padding: 6px; + #AppCard, #RecommendationCard { + background: #ffffff; border: 1px solid #e6ebf5; border-radius: 16px; } - #AppCard:hover { border: 1px solid #8fb1ff; } + #AppCard:hover, #RecommendationCard:hover { border: 1px solid #8fb1ff; } #CardTitle { font-size: 18px; font-weight: 700; } #CardSubtitle { color: #5f6b85; } #CardMeta { color: #7a869f; } @@ -348,6 +397,24 @@ class MainWindow(QMainWindow): background: #ffffff; color: #1d2433; text-align: left; border: 1px solid #e6ebf5; border-radius: 12px; padding: 10px 12px; } #CategoryButton[active="true"] { background: #2d6cff; color: white; border: none; } + #AppIcon { + background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #2d6cff, stop:1 #7aa2ff); + color: white; border-radius: 16px; font-size: 24px; font-weight: 800; + } + #MiniAppIcon { + background: #edf3ff; color: #2d6cff; border-radius: 12px; font-size: 18px; font-weight: 800; + } + #PreviewBanner { + background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #2d6cff, stop:1 #7f5cff); + color: white; border-radius: 18px; font-size: 18px; font-weight: 700; + } + #SourceChip { + background: #f3f6fc; color: #283247; border: 1px solid #dce4f3; border-radius: 10px; padding: 8px 12px; + } + #SourceChip[active="true"] { + background: #2d6cff; color: white; border: none; + } + #RecommendationTitle { font-size: 15px; font-weight: 700; } """) def _init_ui(self) -> None: @@ -445,6 +512,20 @@ class MainWindow(QMainWindow): detail_col = QVBoxLayout() detail_col.setSpacing(16) + preview_panel = QFrame() + preview_panel.setObjectName("PreviewPanel") + preview_layout = QVBoxLayout(preview_panel) + preview_layout.setContentsMargins(18, 18, 18, 18) + preview_layout.setSpacing(12) + preview_layout.addWidget(QLabel("应用展示")) + self.preview_banner = QLabel("选择应用后可在这里展示商店详情头图 / 截图") + self.preview_banner.setObjectName("PreviewBanner") + self.preview_banner.setAlignment(Qt.AlignCenter) + self.preview_banner.setMinimumHeight(170) + self.preview_banner.setWordWrap(True) + preview_layout.addWidget(self.preview_banner) + detail_col.addWidget(preview_panel) + detail_panel = QFrame() detail_panel.setObjectName("DetailPanel") detail_layout = QVBoxLayout(detail_panel) @@ -469,6 +550,11 @@ class MainWindow(QMainWindow): detail_layout.addWidget(self.detail_name) detail_layout.addLayout(top_badges) + detail_layout.addWidget(QLabel("同名应用来源")) + self.source_switch_row = QHBoxLayout() + self.source_switch_row.addStretch(1) + detail_layout.addLayout(self.source_switch_row) + form_box = QGroupBox("详细信息") form = QFormLayout(form_box) self.version_label = QLabel("-") @@ -498,6 +584,16 @@ class MainWindow(QMainWindow): detail_layout.addLayout(button_row) detail_col.addWidget(detail_panel) + relation_panel = QFrame() + relation_panel.setObjectName("RelationPanel") + relation_layout = QVBoxLayout(relation_panel) + relation_layout.setContentsMargins(18, 18, 18, 18) + relation_layout.setSpacing(10) + relation_layout.addWidget(QLabel("你可能还会关注")) + self.recommendation_row = QHBoxLayout() + relation_layout.addLayout(self.recommendation_row) + detail_col.addWidget(relation_panel) + log_panel = QFrame() log_panel.setObjectName("LogPanel") log_layout = QVBoxLayout(log_panel) @@ -546,6 +642,9 @@ class MainWindow(QMainWindow): def _on_search_done(self, records: List[PackageRecord]) -> None: self.records = records + self.records_by_name = defaultdict(list) + for record in records: + self.records_by_name[record.name.lower()].append(record) self.search_btn.setEnabled(True) self.refresh_sources() self._update_metrics(records) @@ -567,15 +666,18 @@ class MainWindow(QMainWindow): btn.style().polish(btn) self.apply_filter() - def clear_cards(self) -> None: - while self.card_layout.count(): - item = self.card_layout.takeAt(0) + def _clear_layout(self, layout) -> None: + while layout.count(): + item = layout.takeAt(0) widget = item.widget() + child_layout = item.layout() if widget: widget.deleteLater() + elif child_layout: + self._clear_layout(child_layout) def apply_filter(self) -> None: - self.clear_cards() + self._clear_layout(self.card_layout) source = self.source_combo.currentText() self.filtered_records = [] for record in self.records: @@ -599,9 +701,12 @@ class MainWindow(QMainWindow): self.category_label.setText("-") self.package_id_label.setText("-") self.detail_state.setText("-") + self.preview_banner.setText("暂无可展示内容") self.install_btn.setEnabled(False) self.remove_btn.setEnabled(False) self.update_btn.setEnabled(False) + self._clear_layout(self.source_switch_row) + self._clear_layout(self.recommendation_row) def show_record(self, record: PackageRecord) -> None: self.current_record = record @@ -616,9 +721,41 @@ class MainWindow(QMainWindow): self.category_label.setText(record.category) self.package_id_label.setText(record.package_id) self.summary_label.setPlainText(record.description or record.summary or "暂无描述") + self.preview_banner.setText( + f"{record.name}\n\n来源:{record.source_badge()}\n分类:{record.category}\n\n这里可继续接入 AppStream 截图、图标与宣传图。" + ) self.install_btn.setEnabled(not record.installed) self.remove_btn.setEnabled(record.installed) self.update_btn.setEnabled(record.installed) + self._populate_source_switch(record) + self._populate_recommendations(record) + + def _populate_source_switch(self, record: PackageRecord) -> None: + self._clear_layout(self.source_switch_row) + candidates = self.records_by_name.get(record.name.lower(), []) + if not candidates: + self.source_switch_row.addStretch(1) + return + for candidate in candidates: + chip = SourceChip(candidate) + chip.setProperty("active", "true" if candidate.package_id == record.package_id and candidate.source == record.source else "false") + chip.style().unpolish(chip) + chip.style().polish(chip) + chip.selected.connect(self.show_record) + self.source_switch_row.addWidget(chip) + self.source_switch_row.addStretch(1) + + def _populate_recommendations(self, record: PackageRecord) -> None: + self._clear_layout(self.recommendation_row) + related = [ + r for r in self.filtered_records + if r.package_id != record.package_id and (r.category == record.category or r.source == record.source) + ][:3] + for candidate in related: + card = RecommendationCard(candidate) + card.clicked.connect(self.show_record) + self.recommendation_row.addWidget(card) + self.recommendation_row.addStretch(1) def run_pkg_action(self, action: str) -> None: if not self.current_record: -- Gitee From 46a079938ed42fcff6544794f1ed54f6ffdfe3a7 Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Sat, 25 Apr 2026 18:22:25 +0800 Subject: [PATCH 5/5] Fix ocstore Docker deployment --- ocstore/Dockerfile | 3 ++- ocstore/docker-compose.yml | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/ocstore/Dockerfile b/ocstore/Dockerfile index 68d6d9e..fdf5165 100644 --- a/ocstore/Dockerfile +++ b/ocstore/Dockerfile @@ -1,4 +1,5 @@ -FROM opencloudos/opencloudos:9 +FROM opencloudos/opencloudos9-openclaw:latest +USER root ENV DEBIAN_FRONTEND=noninteractive \ APP_HOME=/opt/ocstore \ diff --git a/ocstore/docker-compose.yml b/ocstore/docker-compose.yml index 156e353..9f26bce 100644 --- a/ocstore/docker-compose.yml +++ b/ocstore/docker-compose.yml @@ -8,7 +8,7 @@ services: container_name: ocstore ports: - "8088:8080" - - "5901:5901" + - "5902:5901" environment: DISPLAY: ":1" SCREEN_WIDTH: "1440" -- Gitee