From 41be5096dc2d00ed049fc99430637bc51cc326b0 Mon Sep 17 00:00:00 2001 From: shy3130 Date: Tue, 23 Jun 2026 21:47:29 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=A1=8C=E9=9D=A2=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AF=E6=89=93=E5=8C=85=20(pywebview=20+=20PyInstaller)=20+?= =?UTF-8?q?=20=E7=B3=BB=E7=BB=9F=E9=80=9A=E7=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 桌面入口: desktop.py (单实例锁 + 端口探测 + uvicorn + pywebview 窗口) - 配置层: config.py 加 sys.frozen 检测, frozen 下 data_dir 指向用户目录 - 系统通知: notify_adapter.py 三平台 (Win winotify / mac osascript / Linux notify-send) 接入 quote_service 告警出口, preferences 开关控制, 前端监控设置加 toggle - 前端: 监控设置加系统通知开关, 系统设置加检查更新入口 - 打包: packaging/tickflow.spec (onedir, collect-all 原生库, 排除 vectorbt) - CI: .github/workflows/release.yml 手动触发 + 平台可选 (windows/macos/linux) - 依赖: platformdirs/winotify/plyer 入主依赖, pywebview 入 desktop extras - README 加桌面客户端下载章节 - bump 版本号到 0.1.33 --- .github/workflows/release.yml | 155 +++++++++++++ .gitignore | 7 + README.md | 19 +- backend/app/api/settings.py | 17 ++ backend/app/config.py | 64 ++++- backend/app/desktop.py | 241 +++++++++++++++++++ backend/app/services/notify_adapter.py | 136 +++++++++++ backend/app/services/preferences.py | 11 + backend/app/services/quote_service.py | 42 ++++ backend/pyproject.toml | 11 +- backend/uv.lock | 258 ++++++++++++++++++++- frontend/package.json | 2 +- frontend/src/lib/api.ts | 6 + frontend/src/pages/settings/Monitoring.tsx | 27 ++- frontend/src/pages/settings/System.tsx | 39 +++- packaging/tickflow.spec | 166 +++++++++++++ 16 files changed, 1181 insertions(+), 20 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 backend/app/desktop.py create mode 100644 backend/app/services/notify_adapter.py create mode 100644 packaging/tickflow.spec diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b726771 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,155 @@ +name: 桌面客户端发布 + +# 触发方式: 手动 (workflow_dispatch)。 +# 在 GitHub Actions 页面点 "Run workflow", 选择要构建的平台 + 版本号, +# 构建完成后自动创建/更新 Release 并上传安装包。 +# +# 为什么不用 tag 自动触发: +# - 多平台并行构建, mac/linux 未就绪时会自动失败污染 Release +# - 手动触发可精确控制 "今天只发 Windows, 明天补 mac" +# - 版本号通过 input 传入, 与 tag 解耦, 更灵活 +# +# 使用: +# gh workflow run release.yml -f version=v0.2.0 -f platforms=windows +# 或 GitHub 网页 Actions → 桌面客户端发布 → Run workflow +on: + workflow_dispatch: + inputs: + version: + description: '版本号 (如 v0.1.33, 用作 Release tag 和标题)' + required: true + default: 'v0.1.33' + platforms: + description: '要构建的平台 (逗号分隔: windows,macos,linux)' + required: true + default: 'windows' + prerelease: + description: '是否为预发布 (勾选则标记为 Pre-release)' + type: boolean + default: false + +permissions: + contents: write # 创建 Release 需要写权限 + +jobs: + # 第一步: 过滤出本次要构建的平台 + prepare: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.filter.outputs.matrix }} + steps: + - id: filter + run: | + REQUESTED="${{ github.event.inputs.platforms }}" + ALL='[{"os":"windows-latest","artifact":"TickFlowStockPanel-win-x64.zip","platform":"windows"},{"os":"macos-latest","artifact":"TickFlowStockPanel-macos.zip","platform":"macos"},{"os":"ubuntu-latest","artifact":"TickFlowStockPanel-linux-x64.tar.gz","platform":"linux"}]' + # 按 input 过滤平台 + python3 -c " + import json, sys + requested = '${{ github.event.inputs.platforms }}'.split(',') + requested = [r.strip() for r in requested if r.strip()] + all_platforms = json.loads('''$ALL''') + selected = [p for p in all_platforms if p['platform'] in requested] + print('matrix=' + json.dumps({'include': selected})) + with open('$GITHUB_OUTPUT', 'a') as f: + f.write('matrix=' + json.dumps({'include': selected}) + '\n') + " + + # 第二步: 各平台并行构建 + build: + needs: prepare + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false # 单平台失败不影响其他平台 + matrix: ${{ fromJson(needs.prepare.outputs.matrix) }} + steps: + - name: 检出代码 + uses: actions/checkout@v4 + + - name: 安装 pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: 设置 Node 20 + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'pnpm' + cache-dependency-path: frontend/pnpm-lock.yaml + + - name: 构建前端 + working-directory: frontend + run: | + pnpm install --frozen-lockfile + pnpm build + + - name: 安装 uv + uses: astral-sh/setup-uv@v3 + with: + enable-cache: true + + - name: 设置 Python + run: uv python install 3.12 + + - name: 安装后端依赖 (含 desktop, 不含 backtest) + working-directory: backend + # --no-dev 排除 pytest/ruff/mypy; --extra desktop 装 pywebview + # 不加 --extra backtest, 主包不含 vectorbt/numba/llvmlite + run: uv sync --no-dev --extra desktop + + - name: 安装 PyInstaller + run: uv pip install pyinstaller + + - name: 打包 (PyInstaller) + run: uv run pyinstaller packaging/tickflow.spec --noconfirm + + - name: 压缩产物 (Windows) + if: matrix.platform == 'windows' + run: | + cd dist + 7z a -tzip ../${{ matrix.artifact }} TickFlowStockPanel + shell: bash + + - name: 压缩产物 (macOS) + if: matrix.platform == 'macos' + run: | + cd dist + zip -r -q ../${{ matrix.artifact }} TickFlowStockPanel + + - name: 压缩产物 (Linux) + if: matrix.platform == 'linux' + run: | + cd dist + tar -czf ../${{ matrix.artifact }} TickFlowStockPanel + + - name: 上传产物为构建产物 (备查) + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact }} + path: ${{ matrix.artifact }} + + - name: 上传到 GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.event.inputs.version }} + name: ${{ github.event.inputs.version }} + files: ${{ matrix.artifact }} + prerelease: ${{ github.event.inputs.prerelease }} + generate_release_notes: true # 自动从 commit 生成 changelog + body: | + ## 桌面客户端 ${{ github.event.inputs.version }} + + ### 下载 + 选择对应平台的安装包, 解压后双击运行: + + | 平台 | 文件 | + | :--- | :--- | + | **Windows** | `TickFlowStockPanel-win-x64.zip` → 解压运行 `TickFlowStockPanel.exe` | + | **macOS** | `TickFlowStockPanel-macos.zip` → 解压后右键打开 (绕过 Gatekeeper) | + | **Linux** | `TickFlowStockPanel-linux-x64.tar.gz` → 解压运行 | + + ### 说明 + - 数据存储在用户目录 (Windows `%LOCALAPPDATA%`、mac `~/Library`、Linux `~/.local/share`), 卸载重装不丢数据 + - 含纯 Polars 回测引擎; 不含 vectorbt 回测 (为控制体积) + - 系统通知: 设置 → 实时监控 → 系统通知 + - 检查更新: 设置 → 系统设置 → 关于 diff --git a/.gitignore b/.gitignore index e660086..ba5deea 100644 --- a/.gitignore +++ b/.gitignore @@ -89,3 +89,10 @@ data/strategies/custom/ # ===== 临时 / 调试产物 ===== backend._recheck*.py +backend/_verify*.py +backend/_desktop_run.log + +# ===== 打包产物 (本地构建的安装包,不入库) ===== +backend/TickFlowStockPanel-win-x64.zip +backend/TickFlowStockPanel-macos.zip +backend/TickFlowStockPanel-linux-x64.tar.gz diff --git a/README.md b/README.md index 27f6922..75594a7 100644 --- a/README.md +++ b/README.md @@ -172,7 +172,24 @@ docker compose up --build # 打开 http://localhost:3018 ``` -### 方式 B:Dev 模式(二次开发) +### 方式 B:桌面客户端(免环境,开箱即用) + +下载对应平台的安装包,解压后双击运行即可,**无需 Python / Node 环境**。 + +| 平台 | 文件 | +| :--- | :--- | +| **Windows** | `TickFlowStockPanel-win-x64.zip` → 解压后运行 `TickFlowStockPanel.exe` | +| **macOS** | `TickFlowStockPanel-macos.zip` → 解压后运行 app(首次打开右键→打开,绕过 Gatekeeper) | +| **Linux** | `TickFlowStockPanel-linux-x64.tar.gz` → 解压后运行可执行文件 | + +下载地址:**[GitHub Releases](https://github.com/shy3130/tickflow-stock-panel/releases/latest)** + +> - 桌面版数据存储在用户目录(Windows `%APPDATA%`、macOS `~/Library/Application Support`、Linux `~/.local/share`),卸载重装数据不丢失。 +> - 桌面版**不含 vectorbt 回测引擎**(为控制体积);纯 Polars 回测引擎照常可用。 +> - 支持系统通知:在「设置 → 实时监控 → 系统通知」开启,监控告警会推送到操作系统通知中心。 +> - 版本更新:在「设置 → 系统设置 → 关于」点「检查更新」跳转 Release 页下载新版。 + +### 方式 C:Dev 模式(二次开发) ```bash cp .env.example .env # 填 TICKFLOW_API_KEY,留空则启用 Free 试用 diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index 8ffa77b..657f161 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -225,6 +225,7 @@ def get_preferences() -> dict: "sse_refresh_pages": preferences.get_sse_refresh_pages(), "strategy_monitor_enabled": preferences.get_strategy_monitor_enabled(), "strategy_monitor_ids": preferences.get_strategy_monitor_ids(), + "system_notify_enabled": preferences.get_system_notify_enabled(), "sidebar_index_symbols": preferences.get_sidebar_index_symbols(), "nav_order": preferences.get_nav_order(), "nav_hidden": preferences.get_nav_hidden(), @@ -385,6 +386,22 @@ class QuoteIntervalIn(BaseModel): interval: float +class SystemNotifyPrefsIn(BaseModel): + enabled: bool + + +@router.put("/preferences/system-notify") +def update_system_notify(req: SystemNotifyPrefsIn) -> dict: + """系统通知开关 — 开启后监控告警同时推送到操作系统通知中心。 + + 纯偏好, 无副作用 (不像策略监控要迁移规则), 直接落盘即可。 + quote_service 在每轮告警评估时读此开关决定是否发系统通知。 + """ + from app.services import preferences + saved = preferences.set_system_notify_enabled(req.enabled) + return {"system_notify_enabled": saved} + + @router.put("/preferences/quote-interval") def update_quote_interval(req: QuoteIntervalIn, request: Request) -> dict: """更新行情轮询间隔。按档位自动 clamp。""" diff --git a/backend/app/config.py b/backend/app/config.py index 8468076..2dcfe8b 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -1,19 +1,63 @@ """全局配置 — 从环境变量 / .env 读取。""" from __future__ import annotations +import sys from pathlib import Path from pydantic import Field, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict -# 项目根目录 = backend/ 的父目录 -_BACKEND_DIR = Path(__file__).resolve().parent.parent -_PROJECT_ROOT = _BACKEND_DIR.parent +# ── 运行环境检测 ────────────────────────────────────────── +# PyInstaller 打包后: __file__ 指向临时解压目录 _MEIPASS, 不能作为路径基准。 +# 此时: +# - 只读资源 (tiers.yaml / 前端 dist) 放在 _MEIPASS 内 +# - 可写用户数据 (data_dir) 放在可执行文件旁的用户目录 +# 非 frozen 模式 (开发/Docker): 保持原有 __file__ 推导, 行为完全不变。 +_IS_FROZEN = getattr(sys, "frozen", False) + + +def _user_data_root() -> Path: + """桌面版用户数据根目录 (跨平台持久可写)。 + + Windows: %LOCALAPPDATA%/TickFlowStockPanel/TickFlowStockPanel + macOS: ~/Library/Application Support/TickFlowStockPanel + Linux: ~/.local/share/TickFlowStockPanel + + 注意: platformdirs 已含应用名, 切勿再拼一层。 + """ + try: + from platformdirs import user_data_dir + + return Path(user_data_dir("TickFlowStockPanel")) + except Exception: # noqa: BLE001 + # platformdirs 不可用时兜底: 可执行文件旁的 data/ + return Path(sys.executable).resolve().parent / "data" + + +def _resource_root() -> Path: + """只读资源根目录。 + + frozen: PyInstaller 解压目录 (_MEIPASS) + 非 frozen: 项目根目录 (源码树) + """ + if _IS_FROZEN: + # sys._MEIPASS 是 PyInstaller 注入的解压根 + return Path(getattr(sys, "_MEIPASS", Path(sys.executable).resolve().parent)) + return Path(__file__).resolve().parent.parent.parent + + +def _project_root() -> Path: + """项目根目录 (非 frozen 用)。""" + return Path(__file__).resolve().parent.parent.parent + + +_PROJECT_ROOT = _project_root() +_RESOURCE_ROOT = _resource_root() class Settings(BaseSettings): model_config = SettingsConfigDict( - env_file=str(_PROJECT_ROOT / ".env"), + env_file=str(_RESOURCE_ROOT / ".env") if not _IS_FROZEN else ".env", env_file_encoding="utf-8", extra="ignore", ) @@ -34,14 +78,14 @@ class Settings(BaseSettings): log_level: str = "INFO" backtest_range_guard: bool = False - # Data — 默认使用项目根目录的 data/,可通过 DATA_DIR 环境变量覆盖 - data_dir: Path = _PROJECT_ROOT / "data" + # Data — frozen: 用户数据目录; 非 frozen: 项目根目录的 data/ (可被 DATA_DIR 覆盖) + data_dir: Path = _user_data_root() if _IS_FROZEN else (_PROJECT_ROOT / "data") - # tiers.yaml 路径(项目根目录) - tiers_yaml: Path = _PROJECT_ROOT / "tiers.yaml" + # tiers.yaml 路径 — frozen: 资源目录内; 非 frozen: 项目根目录 + tiers_yaml: Path = _RESOURCE_ROOT / "tiers.yaml" if _IS_FROZEN else _PROJECT_ROOT / "tiers.yaml" - # 静态文件(前端 dist) — 部署时只需 rsync 到 frontend/dist - static_dir: Path = _PROJECT_ROOT / "frontend" / "dist" + # 静态文件(前端 dist) — frozen: 资源目录的 static/; 非 frozen: frontend/dist + static_dir: Path = _RESOURCE_ROOT / "static" if _IS_FROZEN else (_PROJECT_ROOT / "frontend" / "dist") @model_validator(mode="after") def _resolve_paths(self) -> Settings: diff --git a/backend/app/desktop.py b/backend/app/desktop.py new file mode 100644 index 0000000..82efaf6 --- /dev/null +++ b/backend/app/desktop.py @@ -0,0 +1,241 @@ +"""桌面客户端入口 — uvicorn 后台服务 + pywebview 桌面窗口。 + +运行方式: + 开发模式: python -m app.desktop (需 pip install pywebview) + 打包后: 双击可执行文件即可 + +职责: + 1. 单实例锁 — 已运行则聚焦已有窗口并退出 + 2. 选可用端口 — 从 settings.port 起, 被占则递增 + 3. 后台线程起 uvicorn (仅监听 127.0.0.1, 不暴露外网) + 4. 主线程起 pywebview 窗口渲染前端 + 5. 窗口关闭 → 优雅停止 uvicorn → 进程退出 + +不含: 业务逻辑、配置持久化、监控告警 (全在 app.main 里)。 +""" +from __future__ import annotations + +import logging +import socket +import sys +import threading +import time +from pathlib import Path + +logger = logging.getLogger(__name__) + +_APP_NAME = "TickFlow 股票面板" +_BASE_PORT = 3018 +_PORT_PROBE_RANGE = 50 # 从 3018 起最多试 50 个端口 + + +def _ensure_data_dir_writable() -> None: + """确保用户数据目录可写 (lifespan 会创建子目录, 这里只验证根目录)。 + + data_dir 在 frozen 模式下指向用户目录 (见 config.py), 非可写会导致 + DuckDB 视图 / parquet 落盘全失败。提前失败胜过启动后乱报错。 + """ + from app.config import settings + + data_root = settings.data_dir + try: + data_root.mkdir(parents=True, exist_ok=True) + probe = data_root / ".write_probe" + probe.write_text("ok", encoding="utf-8") + probe.unlink(missing_ok=True) + except Exception as e: # noqa: BLE001 + logger.error("数据目录不可写, 桌面版无法运行: %s (%s)", data_root, e) + raise + + +def _acquire_single_instance() -> bool: + """单实例锁。已运行返回 False (本进程应退出), 否则 True。 + + 用 data_dir/.desktop.lock 文件锁实现。跨进程, 文件存在即视为已运行 + (简单可靠; 不引入 msvcrt/fcntl 平台差异)。 + """ + from app.config import settings + + lock_path = settings.data_dir / ".desktop.lock" + if lock_path.exists(): + # 软检测: 写入进程 PID, 若该 PID 已不存在则视为残留锁, 允许接管 + try: + pid_str = lock_path.read_text(encoding="utf-8").strip() + pid = int(pid_str) if pid_str.isdigit() else None + except Exception: # noqa: BLE001 + pid = None + + if pid is not None and _pid_alive(pid): + logger.warning("检测到已有实例运行 (PID %d), 本进程退出", pid) + return False + # 残留锁: 清理后继续 + logger.info("清理残留单实例锁 (PID %s 已不存在)", pid) + + lock_path.write_text(str(_current_pid()), encoding="utf-8") + return True + + +def _release_single_instance() -> None: + from app.config import settings + + lock_path = settings.data_dir / ".desktop.lock" + try: + lock_path.unlink(missing_ok=True) + except Exception: # noqa: BLE001 + pass + + +def _pid_alive(pid: int) -> bool: + """检查指定 PID 的进程是否存活。""" + import os + + if os.name == "nt": + # Windows: 0 表示存在, 其它是异常 + try: + os.kill(pid, 0) + return True + except OSError: + return False + else: + try: + os.kill(pid, 0) # signal 0 = 探测存活, 不实际发信号 + return True + except OSError: + return False + + +def _current_pid() -> int: + import os + + return os.getpid() + + +def _find_free_port(start: int, count: int = _PORT_PROBE_RANGE) -> int: + """从 start 起找第一个可用端口。全部被占则返回 start (交给 uvicorn 报错)。""" + for port in range(start, start + count): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + s.bind(("127.0.0.1", port)) + return port + except OSError: + continue + return start + + +def _run_uvmicorn(port: int, ready_event: threading.Event) -> None: + """后台线程: 启动 uvicorn 服务。ready_event 在线程退出时置位 (通知主线程)。""" + import uvicorn + + # 延迟 import app, 确保配置层已就绪 (frozen 检测在 config.py 导入时完成) + from app.main import app + + config = uvicorn.Config( + app, + host="127.0.0.1", # 仅本机, 不暴露外网 (桌面版无需远程访问) + port=port, + log_level="info", + access_log=False, # 桌面版不需要访问日志 + loop="auto", + ) + server = uvicorn.Server(config) + + # 线程结束时通知主线程 (无论正常退出还是异常) + def _signal_done(*exc): + ready_event.set() + server.config.callback_notify = None # 不用 notify 机制 + + try: + server.run() + finally: + ready_event.set() + + +def _wait_for_server(port: int, timeout: float = 60.0) -> bool: + """轮询 health 接口直到后端就绪或超时。 + + 比 monkey-patch uvicorn 内部方法更健壮, 不依赖版本内部实现。 + """ + import urllib.request + import urllib.error + + url = f"http://127.0.0.1:{port}/health" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + with urllib.request.urlopen(url, timeout=2) as r: + if r.status == 200: + return True + except (urllib.error.URLError, ConnectionError, OSError): + pass + time.sleep(0.5) + return False + + +def _open_window(url: str) -> None: + """主线程: 用 pywebview 打开桌面窗口。""" + import webview # type: ignore[import-not-found] + + window = webview.create_window( + _APP_NAME, + url, + width=1440, + height=900, + min_size=(1024, 700), + # 桌面版固定单窗口, 禁用外部浏览器跳转 + confirm_close=False, + ) + # pywebview 会阻塞主线程直到窗口关闭 + webview.start(debug=False) + + +def main() -> int: + """桌面客户端主入口。返回进程退出码。""" + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + ) + + try: + _ensure_data_dir_writable() + except Exception: + # 数据目录不可写是致命错误, 无法继续 + return 1 + + # 单实例: 已运行则退出 + if not _acquire_single_instance(): + return 0 + + try: + port = _find_free_port(_BASE_PORT) + logger.info("桌面版后端将监听 127.0.0.1:%d", port) + + # 后台线程起 uvicorn + ready = threading.Event() + server_thread = threading.Thread( + target=_run_uvmicorn, args=(port, ready), daemon=True, + name="uvicorn", + ) + server_thread.start() + + # 轮询 health 接口等后端就绪 (含 lifespan 初始化, 最多 60s) + if not _wait_for_server(port, timeout=60.0): + logger.error("后端启动超时, 桌面版退出") + _release_single_instance() + return 1 + + url = f"http://127.0.0.1:{port}" + logger.info("打开桌面窗口: %s", url) + _open_window(url) + + # 窗口关闭后, 进程退出 (daemon 线程会被回收) + logger.info("窗口已关闭, 桌面版退出") + return 0 + except KeyboardInterrupt: + return 0 + finally: + _release_single_instance() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/app/services/notify_adapter.py b/backend/app/services/notify_adapter.py new file mode 100644 index 0000000..1d95c17 --- /dev/null +++ b/backend/app/services/notify_adapter.py @@ -0,0 +1,136 @@ +"""系统通知适配器 — 三平台原生通知中心。 + +职责: 把后端产生的告警事件推送到操作系统通知中心。 + 窗口最小化 / 被遮挡 / 后台运行时都能弹通知 (不依赖前端 WebView)。 + +平台实现: + - Windows: winotify (进现代操作中心, 支持图标) + - macOS: osascript (系统已内置, 无需额外依赖) + - Linux: notify-send (系统已内置) / plyer 兜底 + +设计: 失败静默降级, 绝不因通知失败阻断告警主流程 (落盘 / SSE 推送)。 + 通知去重不在本层做, 复用 MonitorRuleEngine 的 cooldown 逻辑。 +""" +from __future__ import annotations + +import logging +import subprocess +import sys +from pathlib import Path + +logger = logging.getLogger(__name__) + +# 单次通知最长字符 (避免某些平台截断报错) +_MAX_LEN = 200 + +# 避免重复探测平台能力, 缓存一次 +_backend_cache: str | None = None + + +def _detect_backend() -> str | None: + """探测当前平台可用的通知后端。""" + global _backend_cache + if _backend_cache is not None: + return _backend_cache if _backend_cache != "none" else None + + backend = None + if sys.platform == "win32": + try: + import winotify # type: ignore[import-not-found] # noqa: F401 + + backend = "winotify" + except ImportError: + logger.debug("winotify 不可用, Windows 通知降级") + backend = None + elif sys.platform == "darwin": + backend = "osascript" + elif sys.platform.startswith("linux"): + backend = "notify-send" + + _backend_cache = backend or "none" + return backend + + +def _truncate(text: str) -> str: + """截断超长文本, 避免平台通知上限报错。""" + text = (text or "").strip() + return text[:_MAX_LEN] + ("…" if len(text) > _MAX_LEN else "") + + +def notify(title: str, message: str, icon: Path | None = None) -> bool: + """推送一条系统通知。 + + Args: + title: 通知标题 + message: 通知正文 + icon: 可选图标路径 (部分平台支持) + + Returns: + True=成功送达, False=失败或无可用后端。 + 失败静默, 不抛异常 (通知是辅助通道, 不能阻断告警主流程)。 + """ + title = _truncate(title) + message = _truncate(message) + if not title: + return False + + backend = _detect_backend() + if backend is None: + return False + + try: + if backend == "winotify": + return _notify_winotify(title, message) + if backend == "osascript": + return _notify_osascript(title, message) + if backend == "notify-send": + return _notify_notify_send(title, message, icon) + except Exception as e: # noqa: BLE001 + logger.debug("系统通知失败 (%s): %s", backend, e) + return False + + return False + + +def _notify_winotify(title: str, message: str) -> bool: + """Windows 通知 (winotify) — 进现代操作中心。""" + from winotify import Notifier # type: ignore[import-not-found] + + Notifier().create_notification( + title=title, + msg=message, + # winotify 要求 duration 为 "short" 或 "long" + duration="short", + # 无可点击动作 (桌面版不实现"点击回到窗口"的复杂交互) + ).show() + return True + + +def _notify_osascript(title: str, message: str) -> bool: + """macOS 通知 (osascript) — 调用系统 AppleScript。""" + # 转义双引号, 避免 AppleScript 注入 + safe_title = title.replace('"', '\\"') + safe_msg = message.replace('"', '\\"') + script = ( + f'display notification "{safe_msg}" with title "{safe_title}"' + ) + result = subprocess.run( # noqa: S603, S607 + ["osascript", "-e", script], + capture_output=True, + timeout=5, + ) + return result.returncode == 0 + + +def _notify_notify_send(title: str, message: str, icon: Path | None) -> bool: + """Linux 通知 (notify-send) — freedesktop.org 标准。""" + args = ["notify-send", title] + if icon and Path(icon).exists(): + args.extend(["--icon", str(icon)]) + args.append(message) + result = subprocess.run( # noqa: S603 + args, + capture_output=True, + timeout=5, + ) + return result.returncode == 0 diff --git a/backend/app/services/preferences.py b/backend/app/services/preferences.py index 8f0f84e..a7e88d9 100644 --- a/backend/app/services/preferences.py +++ b/backend/app/services/preferences.py @@ -205,6 +205,17 @@ def get_strategy_monitor_enabled() -> bool: return load().get("strategy_monitor_enabled", False) +def get_system_notify_enabled() -> bool: + """系统通知开关 — 开启后监控告警同时推送到操作系统通知中心。""" + return load().get("system_notify_enabled", False) + + +def set_system_notify_enabled(enabled: bool) -> bool: + """保存系统通知开关。""" + save({"system_notify_enabled": bool(enabled)}) + return bool(enabled) + + def get_screener_auto_run() -> bool: """选股页进入时是否自动运行所有策略 (获取命中数)。默认开。""" return load().get("screener_auto_run", True) diff --git a/backend/app/services/quote_service.py b/backend/app/services/quote_service.py index 2fd3b7a..03d7a3e 100644 --- a/backend/app/services/quote_service.py +++ b/backend/app/services/quote_service.py @@ -527,9 +527,51 @@ class QuoteService: self._alert_event.set() logger.info("监控评估完成: %d 条通知", len(all_alerts)) + # 系统通知 (可选通道, 由 preferences 开关控制)。 + # cooldown 去重已在 MonitorRuleEngine 做过, 这里只负责转发。 + self._maybe_send_system_notifications(all_alerts) + except Exception as e: # noqa: BLE001 logger.warning("监控评估失败: %s", e) + def _maybe_send_system_notifications(self, all_alerts: list[dict]) -> None: + """把告警转发到操作系统通知中心 (由 preferences 开关控制)。 + + - 开关关闭: 直接返回 + - 开关开启: 逐条发系统通知; 失败静默, 不阻断主流程 + - 去重: 复用 MonitorRuleEngine 的 cooldown, 此处不重复去重 + - 批量策略事件 (symbol="") 聚合为一条通知, 避免刷屏 + """ + try: + from app.services import preferences + from app.services import notify_adapter + + if not preferences.get_system_notify_enabled(): + return + + for ev in all_alerts: + # 通知标题: 用 source 分类 (策略/信号/价格/异动) + source = ev.get("source", "") + source_label = { + "strategy": "策略", "signal": "信号", + "price": "价格", "market": "异动", + }.get(source, source or "通知") + + name = ev.get("name") or "" + symbol = ev.get("symbol") or "" + message = ev.get("message") or "" + + # 正文: 优先用现成 message, 拼上 symbol/name 让用户一眼定位 + if symbol: + body = f"{symbol} {name} {message}".strip() + else: + body = message or name + + title = f"TickFlow · {source_label}" + notify_adapter.notify(title, body) + except Exception as e: # noqa: BLE001 + logger.debug("系统通知发送异常 (不影响告警主流程): %s", e) + def _refresh_strategy_cache(self, enriched_today: pl.DataFrame, enriched_date: date | None) -> None: """利用已计算好的 enriched 数据,运行策略池并写入缓存。""" import math diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 88289ac..2e82018 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "tickflow-stock-panel-backend" -version = "0.1.31" +version = "0.1.33" description = "A 股选股 + 监控 + 回测面板 — TickFlow 适配" readme = "../README.md" requires-python = ">=3.11" @@ -29,6 +29,9 @@ dependencies = [ # AI(可选,但默认装上) "openai>=1.40", # OpenAI 兼容适配器复用 openai SDK "httpx>=0.27", + "platformdirs>=4.0", # 桌面版用户数据目录 (跨平台持久可写) + "winotify>=1.1; sys_platform == 'win32'", # Windows 系统通知 (进操作中心) + "plyer>=2.1", # 系统通知跨平台兜底 (macOS/Linux) ] [project.optional-dependencies] @@ -39,6 +42,12 @@ backtest = [ "vectorbt>=0.26", ] +# 桌面客户端依赖: pywebview 桌面窗口。打包用 (PyInstaller), 生产/Docker 不需要。 +# 启用:`uv sync --extra desktop` +desktop = [ + "pywebview>=5.0", +] + dev = [ "pytest>=8.0", "pytest-asyncio>=0.23", diff --git a/backend/uv.lock b/backend/uv.lock index 8c7fec4..024839c 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -105,6 +105,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047, upload-time = "2025-11-15T16:43:16.109Z" }, ] +[[package]] +name = "bottle" +version = "0.13.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7a/71/cca6167c06d00c81375fd668719df245864076d284f7cb46a694cbeb5454/bottle-0.13.4.tar.gz", hash = "sha256:787e78327e12b227938de02248333d788cfe45987edca735f8f88e03472c3f47", size = 98717, upload-time = "2025-06-15T10:08:59.439Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/f6/b55ec74cfe68c6584163faa311503c20b0da4c09883a41e8e00d6726c954/bottle-0.13.4-py2.py3-none-any.whl", hash = "sha256:045684fbd2764eac9cdeb824861d1551d113e8b683d8d26e296898d3dd99a12e", size = 103807, upload-time = "2025-06-15T10:08:57.691Z" }, +] + [[package]] name = "certifi" version = "2026.5.20" @@ -114,6 +123,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, ] +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.7" @@ -215,6 +250,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, ] +[[package]] +name = "clr-loader" +version = "0.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/46/7eea92b6aa2d68af78e049cbecec5f757f1aad44ecdecdc16bbad7eead51/clr_loader-0.3.1.tar.gz", hash = "sha256:2e073e9aaf49d1ae2f56ecba27987ad5fb68be4bcd9dd34a5bed8f0e4e128366", size = 86805, upload-time = "2026-04-18T17:49:44.287Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/da/ec1a6e36624000b6df0dd61183c42342ee5814c073315e802cadaad04d2f/clr_loader-0.3.1-py3-none-any.whl", hash = "sha256:cbad189de20d202a7d621956b0fc38049e13c9bf7ca2923441eff725cd121aa1", size = 55730, upload-time = "2026-04-18T17:49:42.99Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -1442,6 +1489,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bc/60/5382c03e1970de634027cee8e1b7d39776b778b81812aaf45b694dfe9e28/pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e", size = 7080946, upload-time = "2026-04-01T14:46:11.734Z" }, ] +[[package]] +name = "platformdirs" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, +] + [[package]] name = "plotly" version = "6.7.0" @@ -1464,6 +1520,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "plyer" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/85/f61425aa9be1f9108eec1c13861c1e11c9a04eb786eb4832a8f7188317df/plyer-2.1.0.tar.gz", hash = "sha256:65b7dfb7e11e07af37a8487eb2aa69524276ef70dad500b07228ce64736baa61", size = 121371, upload-time = "2022-11-12T13:36:48.978Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/89/a41c2643fc8eabeb84791acb9d0e4d139b1e4b53473cc4dae947b5fa33ed/plyer-2.1.0-py2.py3-none-any.whl", hash = "sha256:1b1772060df8b3045ed4f08231690ec8f7de30f5a004aa1724665a9074eed113", size = 142266, upload-time = "2022-11-12T13:36:47.181Z" }, +] + [[package]] name = "polars" version = "1.40.1" @@ -1504,6 +1569,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, ] +[[package]] +name = "proxy-tools" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/cf/77d3e19b7fabd03895caca7857ef51e4c409e0ca6b37ee6e9f7daa50b642/proxy_tools-0.1.0.tar.gz", hash = "sha256:ccb3751f529c047e2d8a58440d86b205303cf0fe8146f784d1cbcd94f0a28010", size = 2978, upload-time = "2014-05-05T21:02:24.606Z" } + [[package]] name = "psutil" version = "7.2.2" @@ -1600,6 +1671,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/51/be/6f79d55816d5c22557cf27533543d5d70dfe692adfbee4b99f2760674f38/pyarrow-24.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:c91d00057f23b8d353039520dc3a6c09d8608164c692e9f59a175a42b2ae0c19", size = 28131282, upload-time = "2026-04-21T10:51:16.815Z" }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pydantic" version = "2.13.4" @@ -1740,6 +1820,114 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pyobjc-core" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/b1/729f7458a63758bd21716648a8abcd9a0c8f2d2e9897763c8a1a1c7fd31b/pyobjc_core-12.2.1.tar.gz", hash = "sha256:7a7b9b018402342cf32bf1956366896350fbe5c0478cb3ef59778f77abed7f07", size = 1063383, upload-time = "2026-06-19T16:19:39.357Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/87/16564ef5e4568ee0edd9e712d8111dc8b67621d6bb6ff430646ee2d637dd/pyobjc_core-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:24b76a63caf0b5369d4a377c7c0438cd70df81539057af3db839bfaa3579e04a", size = 6484662, upload-time = "2026-06-19T16:04:44.979Z" }, + { url = "https://files.pythonhosted.org/packages/8c/88/300ad283bed0c971c52dcac6f70113e138169d4ce6d856ddd03d16081e51/pyobjc_core-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a64232bb27ed101d4adc7d42b0e64a6d3331aac7bee7861c037a6777a163f10b", size = 6433347, upload-time = "2026-06-19T16:04:49.341Z" }, + { url = "https://files.pythonhosted.org/packages/3e/1e/b9b0ddffae66996b8779f1f7958adc9f21c13a0448cd3be8d7fe589b5b0f/pyobjc_core-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:af101222762665a4125157906cb4b23f5d5a63d3851d5e0504f72a1eaaa2cfd2", size = 6436004, upload-time = "2026-06-19T16:04:53.257Z" }, + { url = "https://files.pythonhosted.org/packages/8f/26/bd309ede07784c6e5fac4b440c90a5f72a66da7859ed303a9392fe8a5f3f/pyobjc_core-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:efe465e3ecc6fc73f7c7622620345d134a8d34564ab1c29d8247e45f4ed55071", size = 6687044, upload-time = "2026-06-19T16:04:57.42Z" }, + { url = "https://files.pythonhosted.org/packages/bd/8a/cfa4f56939d554dbb342ec6e5226a441e2f552bc2002a0ddf7705bb11bef/pyobjc_core-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2b8fc0531c27277325e113ac00b8a72a82e6145f0a88175b9425d8de814ff69a", size = 6429289, upload-time = "2026-06-19T16:05:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/42/74/446c89bc18103aaa4a00d1fb85ff8acace9a0dc3f362d9678ebf7571e275/pyobjc_core-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9bef500f979e22d54f9da3aaebf6a48f873234b324858bd69256055a318955c7", size = 6690181, upload-time = "2026-06-19T16:05:06.201Z" }, + { url = "https://files.pythonhosted.org/packages/99/c7/0121ee4c616af07ad2de8cd1a286f6978dc9a227eb58b7c2e875cb68a1df/pyobjc_core-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:047c226eeb58a2993ace5e8904e71cc9426ee20d064c617f8fbf32717d37093e", size = 6487078, upload-time = "2026-06-19T16:05:10.093Z" }, + { url = "https://files.pythonhosted.org/packages/b5/a8/cb9fcc150f97d0bf22a2028f88b24cc35949beb1bcc7b8bc5c17d4401677/pyobjc_core-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:1188613805336270279570467e4455b74cb6c0f60913ac74c917ee1c37cfaecb", size = 6733064, upload-time = "2026-06-19T16:05:14.313Z" }, +] + +[[package]] +name = "pyobjc-framework-cocoa" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/51/34/fbe38a204643aa4e1b91391cdce07a34da565a69171ebcad08de7438a556/pyobjc_framework_cocoa-12.2.1.tar.gz", hash = "sha256:b94b37fe5730e5ae1fb0052912cd174e6ec329b0bfba4a012ae5db1014b5864b", size = 3125751, upload-time = "2026-06-19T16:20:05.159Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/d6/dc66ea8519a0475efbccf73f82cc28066339bb300a27f5e1bf91ab1d7002/pyobjc_framework_cocoa-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:dc6da84f4fc62cc25463bbb85e77a57b8d5ac6caf9a60702daf2edb601332f15", size = 387298, upload-time = "2026-06-19T16:07:37.412Z" }, + { url = "https://files.pythonhosted.org/packages/f7/cf/1b3b32b2f28f66cc053c3438ef4e6df36a1591945bf05e7399da18d74553/pyobjc_framework_cocoa-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:28b9b8bab1c36efb94744786918752d0c1842f5fbb67e7d5ca97b5f736512080", size = 388113, upload-time = "2026-06-19T16:07:38.9Z" }, + { url = "https://files.pythonhosted.org/packages/cc/46/68e8e4d926a2f70fed0437047bc3f9fe08af8fe620d94d80656ebc3cfa9b/pyobjc_framework_cocoa-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:3b74a78fa7803e547b32e5e8ec1b49987b52fe318383e793bc6cd49b80efbd9f", size = 388183, upload-time = "2026-06-19T16:07:40.483Z" }, + { url = "https://files.pythonhosted.org/packages/2e/f3/dfc9af4c9eb2e5389c860ad5ef252be9fe456db09f39d537555dc5057aa1/pyobjc_framework_cocoa-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:dc2eaca2f13c7bcd8e41e51a372e47825dea9dd3126108760eed7ba883d2945c", size = 392275, upload-time = "2026-06-19T16:07:42.078Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c8/b90baa8f3592eded79b4be98fb59d2b8dc16b62361e34292bd95806ebd9f/pyobjc_framework_cocoa-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b386c324d64ae565c1f6b7dfb77be68f640a1c7c23caa6966ab661131f519561", size = 388357, upload-time = "2026-06-19T16:07:43.364Z" }, + { url = "https://files.pythonhosted.org/packages/98/d8/64a94651b9294702d55e748d94de30e25bc59d0784526be7643f4467eccd/pyobjc_framework_cocoa-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a6c584e2af0813cb2f6103b184e632665a26f58c1bd5b08ffd6e95a19c617f7b", size = 392404, upload-time = "2026-06-19T16:07:44.955Z" }, + { url = "https://files.pythonhosted.org/packages/5c/cc/26e8a7bf1f5e8caa38b7f80d486296f9fd3c97e71ad7e5444ef22e802758/pyobjc_framework_cocoa-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:b6023657b8d6cc049a21bd6b4752425f2f53c42f9f0b02d64c7608cc484bf103", size = 388589, upload-time = "2026-06-19T16:07:46.276Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/eedf743a303ea742b8e082afe3613fb4d6618bc1a48cf2568b004ce906f7/pyobjc_framework_cocoa-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:c685ccd8e266a07cf912a2c5a13b1f2eff2a868a1aff163b4801b4687bd425e1", size = 392691, upload-time = "2026-06-19T16:07:47.477Z" }, +] + +[[package]] +name = "pyobjc-framework-quartz" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/f6/2a8b84dbf1fe7c04dd96ea73d991678d4e09a909f51971ecc51629bb2ab4/pyobjc_framework_quartz-12.2.1.tar.gz", hash = "sha256:b3b8b6f71e66147f8ff9e6213864cc8527e3a0b1ee90835b93ce221f4802d9b0", size = 3215521, upload-time = "2026-06-19T16:21:30.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/08/527d1ff856e2f2446b5887be01989cc08f9adaf3de7d4eb13d07826c362f/pyobjc_framework_quartz-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60f29408b4f9ed5391a29c6b63e2aa56ddfb8b66b3fb47962930427981e14462", size = 217998, upload-time = "2026-06-19T16:16:02.978Z" }, + { url = "https://files.pythonhosted.org/packages/14/fc/d7c7b3134cdbd1a487f3f77b5be125d87a6c9e7d9411035739d99335cc0c/pyobjc_framework_quartz-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:de9c8cca7e95290c8d540466af11c7cdfe3a5458e6f56c34006d5b45243f9ed9", size = 219000, upload-time = "2026-06-19T16:16:04.29Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4b/861f91a1565d3189ee899e177b915551fb9a7e2ca25414025a8974f04e74/pyobjc_framework_quartz-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:54c9bc7f507192691841ee4eba5bf36990b259df83ac728efed2d7ea1cd021e4", size = 219403, upload-time = "2026-06-19T16:16:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b5/b27010d2f288737f627f74be6d5549f49c841542365c84b9a3011fe39ce7/pyobjc_framework_quartz-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:bfc0d2badd819823d21df8069dcf9544ce360ed747a8895c51bdb25d8d125f45", size = 224458, upload-time = "2026-06-19T16:16:07.252Z" }, + { url = "https://files.pythonhosted.org/packages/8b/5d/85ffd9d433989205d572a50d625c63b29c05e0c5235a725f15ae1023672c/pyobjc_framework_quartz-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:ceb56939c337b36d9d81185ade31f77dc52c85cf79bb16e53e9b32f54b6bb3f5", size = 219769, upload-time = "2026-06-19T16:16:08.814Z" }, + { url = "https://files.pythonhosted.org/packages/e2/d6/b917e4b63d72ea84a27121076f3033f23f6497c0e6ce8d304766c899897f/pyobjc_framework_quartz-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8105c98b798f2bf81c05c54bddeeadbf62f0b5dfec13bd6e719dd2cdf7e1cddf", size = 224717, upload-time = "2026-06-19T16:16:10.215Z" }, + { url = "https://files.pythonhosted.org/packages/04/e2/f3c1ed3228f7430ef5ade23db6f1fcbae99290f177ce5653348fd9e05f4d/pyobjc_framework_quartz-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:bbc214f1a216b5d3651bc832d0ac4589f029f3f37cd6cbb370aac12a7c77942c", size = 219825, upload-time = "2026-06-19T16:16:11.433Z" }, + { url = "https://files.pythonhosted.org/packages/66/2a/2c99a5ad2fe0a11600ea123b8e9a08ff138fcb2ad1e13e376f4bd4aa1d96/pyobjc_framework_quartz-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:ca61624a0b0e6286d8a0f97f47eb9011e4e81e9a339db436d48af527e7065bb1", size = 224770, upload-time = "2026-06-19T16:16:13.035Z" }, +] + +[[package]] +name = "pyobjc-framework-security" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/b8/4267b802d8dba6de468e7d0765b05cc4e146fa376ed9f55e0b6461016bef/pyobjc_framework_security-12.2.1.tar.gz", hash = "sha256:d7831b1537f4346892e7f2f0e2b09d79bee98919b0767f4061278d0e03028f2d", size = 181065, upload-time = "2026-06-19T16:21:40.151Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/ac/f2ff946edfaf16b4ce5e31afac5e519f83705c0f4842fd25134ecb8f2f4a/pyobjc_framework_security-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ce461296b003b2ba17c8b65f6339f9d2fd5dcfa2b3b52ddc0a696334cc8974c5", size = 41306, upload-time = "2026-06-19T16:17:16.816Z" }, + { url = "https://files.pythonhosted.org/packages/4e/5b/2719bc4062e6c27083191fd20e365ae02d0bf1c22f4d1a88211e3d96b369/pyobjc_framework_security-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:76ff6e44e62d3e15651540493879bf16687d862c4f10f3cadade757811c8b8d0", size = 41300, upload-time = "2026-06-19T16:17:17.702Z" }, + { url = "https://files.pythonhosted.org/packages/15/90/dccd4cd6877ef208957dc1f3675287d8614a4dcd2a3ee0a5e56f5fb5a1ba/pyobjc_framework_security-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:990013baba29d6f985d8950b23701129b2597b3d16f628b785fe97596d8a8de3", size = 41299, upload-time = "2026-06-19T16:17:18.511Z" }, + { url = "https://files.pythonhosted.org/packages/ce/af/f9e8040e0c3ef6a50392a46ad1df482a666aa615180d40730b00282ff81f/pyobjc_framework_security-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:066a3e5e9d368e7a6ba8dd52be2077a634ef12a54fbfcc78b3b8154a8f988a1d", size = 42179, upload-time = "2026-06-19T16:17:19.48Z" }, + { url = "https://files.pythonhosted.org/packages/c9/3c/76e2a8bb8d5fe48f0e8e25c6abec1609f3667cc39935017badfe9e9603f2/pyobjc_framework_security-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:5319ae49b8874363ab51c6ff4d85d4ea0cfa6d836fe0306e901ba9ae560b880d", size = 41370, upload-time = "2026-06-19T16:17:20.501Z" }, + { url = "https://files.pythonhosted.org/packages/14/6e/7120956e9833b2c70757eec1f65f57c191e00662cf74c4545d88315643fa/pyobjc_framework_security-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:21618431e0dbfbd3d4029445e3118af88e5d7e52ddecf9a2d17c759c51628d85", size = 42926, upload-time = "2026-06-19T16:17:21.425Z" }, + { url = "https://files.pythonhosted.org/packages/b3/ff/0bafc557523e5755f74dd5363386a1e9b03f611e2e36df0737a508cd5ab4/pyobjc_framework_security-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:fa192e9df479375e6242adcadb9a44f32907dd7fe1207608710cd3af65fe3c84", size = 41376, upload-time = "2026-06-19T16:17:22.337Z" }, + { url = "https://files.pythonhosted.org/packages/47/33/33d266117e46fef148caa4f986b3d896cb9bfd76bef48bd761cb60c758ee/pyobjc_framework_security-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:07cd044a7996f9a897040c49055fa3bdf565acac4a25b834a72e60602376146d", size = 42944, upload-time = "2026-06-19T16:17:23.371Z" }, +] + +[[package]] +name = "pyobjc-framework-uniformtypeidentifiers" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/a1/108fa1e5a3dd8aff626f98fb97de370323b290404b04ffa2ef9420665ed3/pyobjc_framework_uniformtypeidentifiers-12.2.1.tar.gz", hash = "sha256:1fb89d13aa3c2df8e6d6536f6df3493fe5a6caefd2a5adebf17c5af3b29ed4a2", size = 20679, upload-time = "2026-06-19T16:21:55.739Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/44/18a7b3c3b4f9f6784fddf64ed5a2c148577d0300705a50e8ab81da8fc71d/pyobjc_framework_uniformtypeidentifiers-12.2.1-py2.py3-none-any.whl", hash = "sha256:ea08413ad895a7dfea13670e26548bcf5b00154084cdfb5d8f96603320e77cf3", size = 5042, upload-time = "2026-06-19T16:18:54.085Z" }, +] + +[[package]] +name = "pyobjc-framework-webkit" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/11/d2/b230c594f70ecb970b4cef67bae2648d1bfa5b381e9b7e3710bf24ec8887/pyobjc_framework_webkit-12.2.1.tar.gz", hash = "sha256:a56acae55b50d549b20dff2921ad1099add8fbc377d0de09ddc2ba50957f7def", size = 332374, upload-time = "2026-06-19T16:22:01.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/d3/2ab99d3975dd4624dd943e5a7c8d37e40258d3c9fcf4f26baf09a24e6c9b/pyobjc_framework_webkit-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:af5c4ccdf03845adac082823a3b4341b5b2fe62d2d664550afa705b5286a06fc", size = 50264, upload-time = "2026-06-19T16:19:30.607Z" }, + { url = "https://files.pythonhosted.org/packages/84/47/7a2099eb2e062c6230a9440f1795cf34056ca5e16ef25c8aad7c059b8734/pyobjc_framework_webkit-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7e04dcc08cdc59380113ea1232af75a0a04c2426418ebe967b4c0045c973f776", size = 50372, upload-time = "2026-06-19T16:19:31.581Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a4/202ec288808011d3f459d000d593e88b1118f2d1d5a4dfaaf5232f2c2ac2/pyobjc_framework_webkit-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:23bee8bf7077f91da4e3ae54a00c7f5e4414319e15f98be8584dbd67c4043fae", size = 50387, upload-time = "2026-06-19T16:19:32.522Z" }, + { url = "https://files.pythonhosted.org/packages/95/a4/f796e94b43a66704b6ae17c747c7b97fd4b79348f1cfa9bef7b008aaa718/pyobjc_framework_webkit-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:00ffb254f97e9ffdd0a82c1faa61a07f6072ba900fa8aba70c83c21198b52e4e", size = 50853, upload-time = "2026-06-19T16:19:33.43Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f6/d24716fef19ccc3d880e99029458803f0174c05df310d991eb97ea3a0799/pyobjc_framework_webkit-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:67030258c3cd66e8495ccfccef3d2d58010ff0209284c5115e5afdb0e9fd6de1", size = 50499, upload-time = "2026-06-19T16:19:34.45Z" }, + { url = "https://files.pythonhosted.org/packages/a8/6c/817119a52efcc229a30ceff56a0641005a431806a1f555e0571626ba313a/pyobjc_framework_webkit-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:5d91527c9950c79269dd0d70f2bb8668c298dd06930637c1c063ce5f274a87e5", size = 50967, upload-time = "2026-06-19T16:19:35.474Z" }, + { url = "https://files.pythonhosted.org/packages/2d/59/5fac0754d53b2a72aed6f424dfc72e5fa245f83cb57c2e00d02e45390fca/pyobjc_framework_webkit-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:657825081484c9920c50b76b469b9583f116225b0449c9d95c46cbc8c640adc8", size = 50498, upload-time = "2026-06-19T16:19:36.397Z" }, + { url = "https://files.pythonhosted.org/packages/da/0c/e997e33d99d4ad91da2cf70f0e51ac39b03c58ba210548e9e944bbb421be/pyobjc_framework_webkit-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:f46adcc6227873f2b14d74b2e789c937f227722274ab59b9fa3c04c6ecb46dd5", size = 50958, upload-time = "2026-06-19T16:19:37.424Z" }, +] + [[package]] name = "pyparsing" version = "3.3.2" @@ -1808,6 +1996,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1c/fd/0318007beb234790993d3ec5afd051d1dbceb733e81e3afe2b981ece3f37/python_multipart-0.0.30-py3-none-any.whl", hash = "sha256:830964def8c90607ac5daa00514e3987815865713ade8d20febc9177ac0c3c5b", size = 29730, upload-time = "2026-05-31T19:24:53.814Z" }, ] +[[package]] +name = "pythonnet" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "clr-loader", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/57/da1992e44663b71365c6e842c8d7fa453d4ec45fb99a68cfee5b7e944d3c/pythonnet-3.1.0.tar.gz", hash = "sha256:7b34c382905d10a371509ffafd64cae0416305c28817738a9cd138336f4e9991", size = 250599, upload-time = "2026-05-23T20:30:21.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/4b/52414f442624d2589f5374a48c08d5ae94f24bea67fc13a20a752884e5b7/pythonnet-3.1.0-cp310.cp311.cp312.cp313.cp314-none-any.whl", hash = "sha256:698dd88edc198819ad63b624a6ebe76208c7b46e4fe13626f65e484f0358d6ba", size = 217578, upload-time = "2026-05-23T20:30:19.527Z" }, + { url = "https://files.pythonhosted.org/packages/db/67/031124fdcb937c266a3265118525bbf6dc13b8c79786d6a7290aecb6e7bb/pythonnet-3.1.0-cp310.cp311.cp312.cp313.cp314-none-win32.win_amd64.whl", hash = "sha256:7bdd4de03df3547a48122a3989265c8b31d5be0d19dadffa009eec7df8085e0b", size = 1644898, upload-time = "2026-05-23T20:30:16.213Z" }, +] + [[package]] name = "pytz" version = "2026.2" @@ -1817,6 +2018,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126", size = 510141, upload-time = "2026-05-04T01:35:27.408Z" }, ] +[[package]] +name = "pywebview" +version = "6.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bottle" }, + { name = "proxy-tools" }, + { name = "pyobjc-core", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-quartz", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-security", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-uniformtypeidentifiers", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-webkit", marker = "sys_platform == 'darwin'" }, + { name = "pythonnet", marker = "sys_platform == 'win32'" }, + { name = "qtpy", marker = "sys_platform == 'openbsd6'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/4a/05307135dafba67778669d194bd1a3822a7685ec9ee8a6d7e70856c1a551/pywebview-6.2.1.tar.gz", hash = "sha256:71b7136752e40824655304d938efb62014218d1a90bd8e87e1cbdb1ce9c466af", size = 513126, upload-time = "2026-04-15T09:02:16.595Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/25/9491695c22c4842c5b3903b4dc172e0eecf67a27c0af34a71512c9b76a0a/pywebview-6.2.1-py3-none-any.whl", hash = "sha256:9d07275f53894ab4d5e2e0e996227193e7187dec276d9b624dccbce029216b46", size = 525463, upload-time = "2026-04-15T09:02:10.186Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -1872,6 +2095,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "qtpy" +version = "2.4.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/70/01/392eba83c8e47b946b929d7c46e0f04b35e9671f8bb6fc36b6f7945b4de8/qtpy-2.4.3.tar.gz", hash = "sha256:db744f7832e6d3da90568ba6ccbca3ee2b3b4a890c3d6fbbc63142f6e4cdf5bb", size = 66982, upload-time = "2025-02-11T15:09:25.759Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/76/37c0ccd5ab968a6a438f9c623aeecc84c202ab2fabc6a8fd927580c15b5a/QtPy-2.4.3-py3-none-any.whl", hash = "sha256:72095afe13673e017946cc258b8d5da43314197b741ed2890e563cf384b51aa1", size = 95045, upload-time = "2025-02-11T15:09:24.162Z" }, +] + [[package]] name = "regex" version = "2026.5.9" @@ -2235,7 +2470,7 @@ all = [ [[package]] name = "tickflow-stock-panel-backend" -version = "0.1.30" +version = "0.1.33" source = { editable = "." } dependencies = [ { name = "apscheduler" }, @@ -2245,6 +2480,8 @@ dependencies = [ { name = "httpx" }, { name = "openai" }, { name = "pandas" }, + { name = "platformdirs" }, + { name = "plyer" }, { name = "polars" }, { name = "pyarrow" }, { name = "pydantic" }, @@ -2255,12 +2492,16 @@ dependencies = [ { name = "sse-starlette" }, { name = "tickflow", extra = ["all"] }, { name = "uvicorn", extra = ["standard"] }, + { name = "winotify", marker = "sys_platform == 'win32'" }, ] [package.optional-dependencies] backtest = [ { name = "vectorbt" }, ] +desktop = [ + { name = "pywebview" }, +] dev = [ { name = "mypy" }, { name = "pytest" }, @@ -2278,6 +2519,8 @@ requires-dist = [ { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10" }, { name = "openai", specifier = ">=1.40" }, { name = "pandas", specifier = ">=2.2" }, + { name = "platformdirs", specifier = ">=4.0" }, + { name = "plyer", specifier = ">=2.1" }, { name = "polars", specifier = ">=1.0" }, { name = "pyarrow", specifier = ">=16.0" }, { name = "pydantic", specifier = ">=2.7" }, @@ -2286,14 +2529,16 @@ requires-dist = [ { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" }, { name = "python-dotenv", specifier = ">=1.0" }, { name = "python-multipart", specifier = ">=0.0.6" }, + { name = "pywebview", marker = "extra == 'desktop'", specifier = ">=5.0" }, { name = "pyyaml", specifier = ">=6.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.5" }, { name = "sse-starlette", specifier = ">=2.0" }, { name = "tickflow", extras = ["all"], specifier = ">=0.1.23" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.30" }, { name = "vectorbt", marker = "extra == 'backtest'", specifier = ">=0.26" }, + { name = "winotify", marker = "sys_platform == 'win32'", specifier = ">=1.1" }, ] -provides-extras = ["backtest", "dev"] +provides-extras = ["backtest", "desktop", "dev"] [[package]] name = "tqdm" @@ -2636,3 +2881,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/bd/f4/c67440c7fb409a71b wheels = [ { url = "https://files.pythonhosted.org/packages/3f/0e/fa3b193432cfc60c93b42f3be03365f5f909d2b3ea410295cf36df739e31/widgetsnbextension-4.0.15-py3-none-any.whl", hash = "sha256:8156704e4346a571d9ce73b84bee86a29906c9abfd7223b7228a28899ccf3366", size = 2196503, upload-time = "2025-11-01T21:15:53.565Z" }, ] + +[[package]] +name = "winotify" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/b0/1b304fdd8fd810f1f8e81a2708f8bf72e0987de1a763f0ee81f6d08bcae7/winotify-1.1.0.tar.gz", hash = "sha256:f8a0d6ff00cb2c1b3dcdfe825431f46f6aa5dc8ce84ffc59e8fda8c7e36687fe", size = 10101, upload-time = "2022-02-07T12:34:46.236Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/85/6cc4c738080d60b62cad59d0f32386b8277d40e2bf8c06fd1e4101a17238/winotify-1.1.0-py3-none-any.whl", hash = "sha256:13aa9b1196b02ab3e699645b4407371ca73348421f8662565100d70c7cf552d9", size = 15034, upload-time = "2022-02-07T12:34:44.341Z" }, +] diff --git a/frontend/package.json b/frontend/package.json index a3592bf..e641940 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "tickflow-stock-panel-frontend", "private": true, - "version": "0.1.31", + "version": "0.1.33", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index ca0abcc..7bfa3eb 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -583,6 +583,7 @@ export interface Preferences { sse_refresh_pages: Record strategy_monitor_enabled: boolean strategy_monitor_ids: string[] + system_notify_enabled: boolean sidebar_index_symbols: string[] nav_order: string[] nav_hidden: string[] @@ -686,6 +687,11 @@ export const api = { method: 'PUT', body: JSON.stringify(cfg), }), + updateSystemNotify: (enabled: boolean) => + request<{ system_notify_enabled: boolean }>('/api/settings/preferences/system-notify', { + method: 'PUT', + body: JSON.stringify({ enabled }), + }), updatePipelineSchedule: (hour: number, minute: number) => request<{ hour: number; minute: number }>('/api/settings/preferences/pipeline-schedule', { method: 'PUT', diff --git a/frontend/src/pages/settings/Monitoring.tsx b/frontend/src/pages/settings/Monitoring.tsx index 6e38d90..c24566f 100644 --- a/frontend/src/pages/settings/Monitoring.tsx +++ b/frontend/src/pages/settings/Monitoring.tsx @@ -7,6 +7,7 @@ import { BarChart3, Flame, Zap, + Bell, } from 'lucide-react' import { usePreferences, @@ -48,6 +49,7 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } = const realtimeEnabled = prefs?.realtime_quotes_enabled ?? false const refreshPages = prefs?.sse_refresh_pages ?? {} const limitLadderMonitor = prefs?.limit_ladder_monitor_enabled ?? false + const systemNotify = prefs?.system_notify_enabled ?? false const hasDepth = !!caps?.capabilities?.['depth5.batch'] const sidebarIndexSymbols = prefs?.sidebar_index_symbols ?? SIDEBAR_INDEX_OPTIONS.map(i => i.symbol) const indicesPinned = prefs?.indices_nav_pinned ?? true @@ -91,6 +93,11 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } = qc.invalidateQueries({ queryKey: QK.preferences }) }, [qc]) + const toggleSystemNotify = useCallback(async (enabled: boolean) => { + await api.updateSystemNotify(enabled) + qc.invalidateQueries({ queryKey: QK.preferences }) + }, [qc]) + const runFix = useMutation({ mutationFn: () => api.runLimitLadderFix(), onSuccess: (data) => { @@ -239,6 +246,15 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } = > 前往监控中心配置 → +
+ +
{/* 连板梯队降级修正 */} @@ -300,17 +316,22 @@ function ToggleRow({ desc, checked, onChange, + icon: Icon, }: { label: string desc: string checked: boolean onChange: (v: boolean) => void + icon?: React.ComponentType<{ className?: string }> }) { return (
-
-
{label}
-
{desc}
+
+ {Icon && } +
+
{label}
+
{desc}
+
+ +
+
+ +

关于

+
+ +
+
+
版本
+
当前安装的应用版本
+
+ + {versionData?.version ?? '—'} + +
+ +
+
+
检查更新
+
前往 GitHub Releases 下载最新版本
+
+ + + 检查更新 + +
+
) } diff --git a/packaging/tickflow.spec b/packaging/tickflow.spec new file mode 100644 index 0000000..3b6a9e7 --- /dev/null +++ b/packaging/tickflow.spec @@ -0,0 +1,166 @@ +# -*- mode: python ; coding: utf-8 -*- +"""PyInstaller 打包配置 — 桌面客户端 (onedir 模式)。 + +为什么 onedir 而非 onefile: + - onefile 每次启动都解压到临时 _MEIxxxxx, 与 APScheduler/多线程冲突 + - onedir 启动更快, 调试更方便 (可看到目录结构), 原生库直接在目录里 + - 体积差异通过压缩安装包弥补 (CI 里 zip 打包) + +入口: backend/app/desktop.py (桌面版入口, 含 uvicorn + pywebview) + +构建 (在项目根目录): + cd frontend && pnpm build # 先构建前端到 frontend/dist + pyinstaller packaging/tickflow.spec # 产物在 dist/TickFlowStockPanel/ +""" +import sys +from pathlib import Path + +from PyInstaller.utils.hooks import ( + collect_all, + collect_submodules, + collect_data_files, + copy_metadata, +) + +block_cipher = None + +# ── 资源路径基准: 项目根 (spec 文件在 packaging/ 下) ────────────────── +ROOT = Path(SPECPATH).parent +FRONTEND_DIST = str(ROOT / "frontend" / "dist") +TIERS_YAML = str(ROOT / "tiers.yaml") +BUILTIN_STRATEGIES = str(ROOT / "backend" / "app" / "strategy" / "builtin") + +# ── 收集带原生库的依赖 (.libs/ 目录必须完整, 否则启动崩) ───────────── +# polars / pyarrow / duckdb / fastexcel 都自带共享库子目录 +datas = [] +binaries = [] +hiddenimports = [] + +for pkg in ("polars", "pyarrow", "duckdb", "fastexcel"): + d, b, h = collect_all(pkg) + datas += d + binaries += b + hiddenimports += h + +# polars 新 ABI 运行时目录 (_polars_runtime_32) 需显式收集子模块 +hiddenimports += collect_submodules("polars") + +# ── pywebview 平台后端 (动态导入, PyInstaller 默认抓不到) ──────────── +hiddenimports += collect_submodules("webview") +hiddenimports += collect_submodules("webview.platforms") + +# ── 系统通知后端 (winotify/plyer 按平台动态导入) ───────────────────── +if sys.platform == "win32": + hiddenimports += collect_submodules("winotify") +hiddenimports += collect_submodules("plyer") +hiddenimports += collect_submodules("plyer.platforms") + +# ── uvicorn 动态导入的模块 (loop/protocol/logging 按字符串加载) ────── +hiddenimports += [ + "uvicorn.logging", + "uvicorn.loops", + "uvicorn.loops.auto", + "uvicorn.loops.asyncio", + "uvicorn.protocols", + "uvicorn.protocols.http", + "uvicorn.protocols.http.auto", + "uvicorn.protocols.http.h11_impl", + "uvicorn.protocols.websockets", + "uvicorn.protocols.websockets.auto", + "uvicorn.lifespan", + "uvicorn.lifespan.on", +] + +# ── fastapi / pydantic 元数据 (版本检测用) ─────────────────────────── +# 注意: 任何用 importlib.metadata.version() 读版本的包, 都必须 copy_metadata, +# 否则 frozen 后报 PackageNotFoundError。tickflow 包内部就是这么读的。 +# 用容错写法: 不存在的包跳过, 避免不同环境 (有无装某依赖) 导致构建失败。 +def _safe_metadata(pkg): + """收集包元数据, 包不存在时静默跳过。""" + try: + return copy_metadata(pkg) + except Exception: + return [] + +for pkg in ( + "fastapi", "pydantic", "pydantic_settings", "starlette", "anyio", + "tickflow", # tickflow/__version__.py 用 importlib.metadata 读版本 + "uvicorn", "polars", "duckdb", "pyarrow", "httpx", "numpy", "pandas", + "openai", "platformdirs", "winotify", "plyer", "apscheduler", + "python-dotenv", "fastexcel", +): + datas += _safe_metadata(pkg) + +# ── 随包资源 (只读, 放进 _MEIPASS) ──────────────────────────────────── +# 前端 dist → static/ (config.py frozen 模式读 _MEIPASS/static) +datas += [(FRONTEND_DIST, "static")] +# tiers.yaml → 包根 (config.py frozen 模式读 _MEIPASS/tiers.yaml) +datas += [(TIERS_YAML, ".")] +# 内置策略 → app/strategy/builtin/ (importlib 动态加载, 不能进 PYZ) +datas += [(BUILTIN_STRATEGIES, "app/strategy/builtin")] + +# ── 排除不需要的重型依赖 (主包不含 vectorbt 回测链) ────────────────── +excludes = [ + "vectorbt", + "numba", + "llvmlite", + "matplotlib", + "plotly", + "ipywidgets", + "nbformat", + "nbconvert", + "jupyter", + "IPython", + "pytest", + "pytest_asyncio", + "ruff", + "mypy", +] + +a = Analysis( + [str(ROOT / "backend" / "app" / "desktop.py")], + pathex=[str(ROOT / "backend")], + binaries=binaries, + datas=datas, + hiddenimports=hiddenimports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=excludes, + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False, +) + +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name="TickFlowStockPanel", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=False, # UPX 压缩原生库常导致崩溃, 关闭 + console=False, # 桌面应用: 不显示控制台窗口 (调试时临时改 True 抓日志) + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon=None, # TODO: 添加应用图标 (后续设计后填路径) +) + +coll = COLLECT( + exe, + a.binaries, + a.zipfiles, + a.datas, + strip=False, + upx=False, + upx_exclude=[], + name="TickFlowStockPanel", +)