Compare commits
17 Commits
@@ -1,28 +0,0 @@
|
||||
.git
|
||||
.gitignore
|
||||
.dockerignore
|
||||
|
||||
.venv
|
||||
venv
|
||||
env
|
||||
|
||||
__pycache__
|
||||
*.py[cod]
|
||||
*.pyo
|
||||
*.pyd
|
||||
.pytest_cache
|
||||
.ruff_cache
|
||||
.mypy_cache
|
||||
.pyre
|
||||
.coverage
|
||||
htmlcov
|
||||
|
||||
data
|
||||
.v2rayA
|
||||
|
||||
*.tmp
|
||||
*.log
|
||||
|
||||
Dockerfile
|
||||
docs
|
||||
tests
|
||||
@@ -1,84 +0,0 @@
|
||||
name: Docker Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "**"
|
||||
tags:
|
||||
- "v*"
|
||||
paths:
|
||||
- ".github/workflows/**"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: docker.pchuan.top
|
||||
IMAGE_NAME: pyxray
|
||||
APT_MIRROR: https://mirrors.ustc.edu.cn/debian
|
||||
UV_INDEX_URL: https://pypi.mirrors.ustc.edu.cn/simple/
|
||||
|
||||
jobs:
|
||||
docker-build:
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Read project version
|
||||
id: version
|
||||
shell: bash
|
||||
run: |
|
||||
version="$(awk -F '"' '/^version = / { print $2; exit }' pyproject.toml)"
|
||||
|
||||
if [ -z "$version" ]; then
|
||||
echo "Failed to read project.version from pyproject.toml."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "version=${version}" >> "$GITHUB_OUTPUT"
|
||||
echo "image=${REGISTRY}/${IMAGE_NAME}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Validate git tag version
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
shell: bash
|
||||
env:
|
||||
PROJECT_VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
tag="${GITHUB_REF_NAME}"
|
||||
expected="v${PROJECT_VERSION}"
|
||||
|
||||
if [ "$tag" != "$expected" ]; then
|
||||
echo "Git tag '${tag}' does not match pyproject.toml version '${PROJECT_VERSION}'. Expected '${expected}'."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Build Docker image
|
||||
shell: bash
|
||||
env:
|
||||
IMAGE: ${{ steps.version.outputs.image }}
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
docker build \
|
||||
--build-arg "APT_MIRROR=${APT_MIRROR}" \
|
||||
--build-arg "UV_INDEX_URL=${UV_INDEX_URL}" \
|
||||
-t "${IMAGE}:latest" \
|
||||
-t "${IMAGE}:${VERSION}" \
|
||||
.
|
||||
|
||||
- name: Login Docker registry
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
shell: bash
|
||||
run: |
|
||||
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login "${REGISTRY}" \
|
||||
-u "${{ secrets.REGISTRY_USERNAME }}" \
|
||||
--password-stdin
|
||||
|
||||
- name: Push Docker image
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
shell: bash
|
||||
env:
|
||||
IMAGE: ${{ steps.version.outputs.image }}
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
docker push "${IMAGE}:latest"
|
||||
docker push "${IMAGE}:${VERSION}"
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.pyo
|
||||
*.pyd
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.mypy_cache/
|
||||
.pyre/
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# Virtual environments
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
|
||||
# Local runtime data
|
||||
nodes.toml
|
||||
state.toml
|
||||
settings.toml
|
||||
runtime.toml
|
||||
config.json
|
||||
xray.log
|
||||
xray/
|
||||
*.tmp
|
||||
|
||||
.v2rayA
|
||||
data
|
||||
-51
@@ -1,51 +0,0 @@
|
||||
FROM ghcr.io/astral-sh/uv:python3.14-bookworm-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ARG APT_MIRROR="https://mirrors.ustc.edu.cn/debian"
|
||||
ARG UV_INDEX_URL="https://pypi.mirrors.ustc.edu.cn/simple/"
|
||||
|
||||
ENV UV_INDEX_URL="${UV_INDEX_URL}" \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYXRAY_HOST=0.0.0.0 \
|
||||
PYXRAY_PORT=8080 \
|
||||
PYXRAY_XRAY_DIR=/config/xray
|
||||
|
||||
RUN if [ -f /etc/apt/sources.list.d/debian.sources ]; then \
|
||||
sed -i "s|http://deb.debian.org/debian|${APT_MIRROR}|g; s|http://deb.debian.org/debian-security|${APT_MIRROR}-security|g" /etc/apt/sources.list.d/debian.sources; \
|
||||
fi \
|
||||
&& if [ -f /etc/apt/sources.list ]; then \
|
||||
sed -i "s|http://deb.debian.org/debian|${APT_MIRROR}|g; s|http://security.debian.org/debian-security|${APT_MIRROR}-security|g" /etc/apt/sources.list; \
|
||||
fi \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
iproute2 \
|
||||
iptables \
|
||||
nftables \
|
||||
unzip \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY pyproject.toml uv.lock README.md ./
|
||||
COPY pyxray ./pyxray
|
||||
|
||||
RUN uv sync --frozen --no-dev \
|
||||
&& uv pip install --python /app/.venv/bin/python gunicorn==23.0.0
|
||||
|
||||
RUN printf '%s\n' \
|
||||
'#!/bin/sh' \
|
||||
'set -eu' \
|
||||
'mkdir -p "$PYXRAY_XRAY_DIR"' \
|
||||
'exec /app/.venv/bin/gunicorn --bind "$PYXRAY_HOST:$PYXRAY_PORT" --workers 1 --threads 8 --timeout 120 --access-logfile - --error-logfile - "pyxray.web.server:create_app(\"$PYXRAY_XRAY_DIR\")"' \
|
||||
> /usr/local/bin/pyxray-entrypoint \
|
||||
&& chmod 0755 /usr/local/bin/pyxray-entrypoint
|
||||
|
||||
VOLUME ["/config"]
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD curl -fsS "http://127.0.0.1:${PYXRAY_PORT}/" >/dev/null || exit 1
|
||||
|
||||
CMD ["/usr/local/bin/pyxray-entrypoint"]
|
||||
@@ -1,137 +1,16 @@
|
||||
# pyxray 使用手册
|
||||
# pyxray dev
|
||||
|
||||
`pyxray` 是一个轻量级 Xray 控制面板。它负责下载 Xray 运行资源、导入代理节点、生成 Xray 配置、启动/停止 Xray,并可在 Linux/Docker 环境下为宿主机提供透明代理。
|
||||
This workspace contains the rewrite of pyxray.
|
||||
|
||||
当前实现重点:
|
||||
The old implementation is kept in `../main`. New code should keep backend, frontend, and protocol boundaries separate.
|
||||
|
||||
| 能力 | 状态 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| Xray 资源管理 | 已实现 | 下载/检查 `xray`、`geoip.dat`、`geosite.dat`。 |
|
||||
| 节点导入 | 已实现 | 支持 `vless`、`vmess`、`trojan`、`trojan-go`、`shadowsocks`。 |
|
||||
| 配置生成 | 已实现 | 根据选中节点和 `settings.toml` 生成 `config.json`。 |
|
||||
| Xray 运行控制 | 已实现 | Web 内启动/停止由 pyxray 托管的 Xray 子进程。 |
|
||||
| 透明代理 | 已实现 | 生成并执行 `redirect` / `tproxy` / `system_proxy` / `tun` 相关配置和脚本。 |
|
||||
| 订阅 | 未实现 | 当前只支持手动导入节点链接。 |
|
||||
| 登录认证 | 未实现 | 默认不要直接暴露到不可信网络。 |
|
||||
|
||||
## 运行机制
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
UI[Web UI] --> API[Flask/Gunicorn API]
|
||||
API --> Nodes[nodes.toml]
|
||||
API --> Settings[settings.toml]
|
||||
API --> Assets[download.toml + xray assets]
|
||||
API --> Gen[config.json + transparent scripts]
|
||||
Gen --> Xray[Xray process]
|
||||
API --> Runtime[TransparentRuntime]
|
||||
Runtime --> HostNet[iptables/nft/ip rule/resolv.conf]
|
||||
Xray --> Proxy[Selected outbound node]
|
||||
```
|
||||
|
||||
默认数据目录:
|
||||
|
||||
| 文件/目录 | 作用 |
|
||||
| --- | --- |
|
||||
| `data/nodes.toml` | 保存节点列表和当前选中节点。 |
|
||||
| `data/settings.toml` | 保存配置页设置。 |
|
||||
| `data/download.toml` | 保存下载页设置。 |
|
||||
| `data/config.json` | 生成给 Xray 使用的配置。 |
|
||||
| `data/xray/` | 保存 `xray`、`geoip.dat`、`geosite.dat`。 |
|
||||
| `data/transparent/` | 保存透明代理脚本、nftables 配置和 `tinytun.yaml`。 |
|
||||
| `data/xray.log` | 保存 Xray 输出和 pyxray 运行日志。 |
|
||||
|
||||
## Docker 部署
|
||||
|
||||
构建镜像:
|
||||
|
||||
```bash
|
||||
sh scripts/build.sh
|
||||
```
|
||||
|
||||
启动服务:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
访问:
|
||||
## Layout
|
||||
|
||||
```text
|
||||
http://<host-ip>:8080
|
||||
backend/ Python backend, CLI, API server, and core services.
|
||||
frontend/ TypeScript frontend. It talks to the backend only through HTTP APIs.
|
||||
protocol/ API contracts such as OpenAPI and shared schemas.
|
||||
docs/ Architecture and design notes.
|
||||
```
|
||||
|
||||
当前 `compose.yaml` 使用:
|
||||
|
||||
| 配置 | 作用 |
|
||||
| --- | --- |
|
||||
| `network_mode: host` | 让容器直接使用宿主机网络,透明代理规则作用于宿主机网络栈。 |
|
||||
| `privileged: true` | 允许执行 `iptables`、`nft`、`ip rule`、写 `/proc/sys/net/...`。 |
|
||||
| `./data:/config` | 持久化 pyxray 数据和 Xray 资源。 |
|
||||
| `/etc/resolv.conf:/etc/resolv.conf` | 允许 DNS 劫持脚本修改宿主机 DNS。 |
|
||||
| `/lib/modules:/lib/modules:ro` | 读取宿主机内核模块信息。 |
|
||||
|
||||
## Web 使用流程
|
||||
|
||||
1. 打开 Web 控制台。
|
||||
2. 在“下载”页检查或下载 Xray 资源。
|
||||
3. 在“节点”页导入节点链接。
|
||||
4. 选择一个当前节点。
|
||||
5. 在“配置”页调整入站、路由、DNS、透明代理。
|
||||
6. 保存设置。
|
||||
7. 点击“启动 Xray”。
|
||||
8. 在“日志”页确认 Xray 和透明代理脚本执行结果。
|
||||
|
||||
## 透明代理建议
|
||||
|
||||
| 场景 | 建议 |
|
||||
| --- | --- |
|
||||
| 只代理本机 TCP 流量 | `transparent.mode = proxy`,`transparent.type = redirect`。 |
|
||||
| 国内直连、国外代理 | `transparent.mode = whitelist`,`transparent.type = redirect`。 |
|
||||
| 需要 UDP/TProxy | 使用 `transparent.type = tproxy`,确认宿主机内核和防火墙支持。 |
|
||||
| 只想给应用显式设置代理 | 使用 `system_proxy` 或普通 rule HTTP/SOCKS 入站。 |
|
||||
| Docker 容器流量也要透明代理 | 开启 `docker_transparent` 并确认 `docker_transparent_cidrs` 覆盖实际 Docker 网段。 |
|
||||
|
||||
## 注意事项
|
||||
|
||||
| 项目 | 说明 |
|
||||
| --- | --- |
|
||||
| 端口冲突 | 启动 Xray 前会检查生成配置里的入站端口是否可用。 |
|
||||
| 透明代理权限 | Docker 透明代理部署需要 `network_mode: host` 和 `privileged: true`。 |
|
||||
| DNS 劫持 | `redirect + local_dns_listen` 会改写 `/etc/resolv.conf`。 |
|
||||
| 多 worker | 不要把 Gunicorn 改成多 worker;当前内存任务表和 Xray 子进程状态不能跨进程共享。 |
|
||||
| Web 暴露 | 当前没有认证,建议只在可信局域网使用。 |
|
||||
| 自动订阅 | 当前不支持订阅更新,节点需要手动导入。 |
|
||||
|
||||
## 阅读和修改代码
|
||||
|
||||
| 路径 | 职责 |
|
||||
| --- | --- |
|
||||
| `pyxray/cli.py` | CLI 入口,默认启动 Web。 |
|
||||
| `pyxray/web/server.py` | Flask app 装配。 |
|
||||
| `pyxray/web/*.py` | Web API:节点、下载、配置生成、服务控制。 |
|
||||
| `pyxray/web/templates/` | Web UI 模板。 |
|
||||
| `pyxray/web/static/` | 前端交互逻辑和样式。 |
|
||||
| `pyxray/libs/nodes/` | 节点链接解析、标准化、持久化。 |
|
||||
| `pyxray/libs/xray_config/` | Xray JSON、透明代理脚本、TinyTun 配置生成。 |
|
||||
| `pyxray/libs/xray_runtime.py` | Xray 子进程生命周期和日志转发。 |
|
||||
| `pyxray/libs/xray_transparent_runtime.py` | 透明代理脚本执行、回滚和本地 CIDR watcher。 |
|
||||
| `tests/` | 单元测试和 Web API 测试。 |
|
||||
|
||||
详细结构和调用时序见 [docs/infra.md](docs/infra.md)。
|
||||
|
||||
## 配置文档
|
||||
|
||||
配置总览见 [docs/config.md](docs/config.md)。
|
||||
|
||||
按分类阅读:
|
||||
|
||||
| 分类 | 文档 |
|
||||
| --- | --- |
|
||||
| 核心 | [docs/config/core.md](docs/config/core.md) |
|
||||
| 入站 | [docs/config/inbounds.md](docs/config/inbounds.md) |
|
||||
| 路由 | [docs/config/routing.md](docs/config/routing.md) |
|
||||
| DNS | [docs/config/dns.md](docs/config/dns.md) |
|
||||
| 透明代理 | [docs/config/transparent.md](docs/config/transparent.md) |
|
||||
| 出站和自动更新 | [docs/config/outbounds-auto-update.md](docs/config/outbounds-auto-update.md) |
|
||||
| Xray 资源下载 | [docs/config/assets.md](docs/config/assets.md) |
|
||||
No service interfaces are committed yet. Define the protocol and architecture first, then add backend interfaces.
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
data-test-*/
|
||||
data/
|
||||
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
|
||||
*.log
|
||||
|
||||
.env
|
||||
.env.*
|
||||
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
@@ -0,0 +1,5 @@
|
||||
# pyxray backend
|
||||
|
||||
Internal backend runtime for the pyxray rewrite.
|
||||
|
||||
This package does not expose web APIs directly yet. Modules are designed to be reused by future CLI and API adapters.
|
||||
@@ -0,0 +1,43 @@
|
||||
[project]
|
||||
name = "pyxray-backend"
|
||||
version = "0.1.0"
|
||||
description = "Backend runtime for the pyxray rewrite."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"fastapi>=0.116.0",
|
||||
"httpx>=0.28.1",
|
||||
"loguru>=0.7.3",
|
||||
"pydantic>=2.11.0",
|
||||
"typer>=0.16.0",
|
||||
"tomlkit>=0.13.3",
|
||||
"uvicorn>=0.35.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
pyxray = "pyxray.cli:app"
|
||||
pyxray-api = "pyxray.api.server:main"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=8.3.0",
|
||||
"ruff>=0.11.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["pyxray"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
pythonpath = ["."]
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py312"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "UP", "B"]
|
||||
@@ -1,4 +1,4 @@
|
||||
"""pyxray package."""
|
||||
"""pyxray backend runtime."""
|
||||
|
||||
__all__ = ["__version__"]
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""HTTP API adapters."""
|
||||
@@ -0,0 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from pyxray.api.assets import router as assets_router
|
||||
from pyxray.assets.service import AssetsService
|
||||
|
||||
|
||||
def create_app(workdir: str | Path | None = None) -> FastAPI:
|
||||
app = FastAPI(title="pyxray backend", version="0.1.0")
|
||||
app.state.assets_service = AssetsService.from_workdir(workdir)
|
||||
app.include_router(assets_router, prefix="/api/v1")
|
||||
return app
|
||||
@@ -0,0 +1,78 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Request, status
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from pyxray.assets.models import (
|
||||
AssetsDownloadRequest,
|
||||
AssetsTaskStartResponse,
|
||||
AssetsUpdateRequest,
|
||||
CoreConfigText,
|
||||
ErrorResponse,
|
||||
)
|
||||
from pyxray.assets.service import AssetsService, AssetsTaskConflictError
|
||||
from pyxray.xray.models import DownloadTaskStatus, XrayStatus
|
||||
|
||||
router = APIRouter(prefix="/assets", tags=["assets"])
|
||||
|
||||
|
||||
def _service(request: Request) -> AssetsService:
|
||||
return request.app.state.assets_service
|
||||
|
||||
|
||||
@router.get("/config", response_model=CoreConfigText)
|
||||
def get_assets_config(request: Request) -> CoreConfigText:
|
||||
return _service(request).config_text()
|
||||
|
||||
|
||||
@router.get("/status", response_model=XrayStatus)
|
||||
def get_assets_status(request: Request) -> XrayStatus:
|
||||
return _service(request).status()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/update",
|
||||
response_model=AssetsTaskStartResponse,
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
responses={409: {"model": ErrorResponse}},
|
||||
)
|
||||
def update_assets(
|
||||
request: Request,
|
||||
body: AssetsUpdateRequest | None = None,
|
||||
) -> AssetsTaskStartResponse | JSONResponse:
|
||||
try:
|
||||
return _service(request).update(body or AssetsUpdateRequest())
|
||||
except AssetsTaskConflictError as exc:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
content=ErrorResponse(error=str(exc)).model_dump(),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/download",
|
||||
response_model=AssetsTaskStartResponse,
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
responses={409: {"model": ErrorResponse}},
|
||||
)
|
||||
def download_assets(
|
||||
request: Request,
|
||||
body: AssetsDownloadRequest | None = None,
|
||||
) -> AssetsTaskStartResponse | JSONResponse:
|
||||
try:
|
||||
return _service(request).download(body or AssetsDownloadRequest())
|
||||
except AssetsTaskConflictError as exc:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
content=ErrorResponse(error=str(exc)).model_dump(),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/download/task", response_model=DownloadTaskStatus)
|
||||
def get_assets_download_task(request: Request) -> DownloadTaskStatus:
|
||||
return _service(request).task_status()
|
||||
|
||||
|
||||
@router.post("/download/cancel", response_model=DownloadTaskStatus)
|
||||
def cancel_assets_download(request: Request) -> DownloadTaskStatus:
|
||||
return _service(request).cancel()
|
||||
@@ -0,0 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uvicorn
|
||||
|
||||
|
||||
def main() -> None:
|
||||
uvicorn.run(
|
||||
"pyxray.api.app:create_app",
|
||||
factory=True,
|
||||
host="127.0.0.1",
|
||||
port=8000,
|
||||
reload=True,
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""Asset management service layer."""
|
||||
@@ -0,0 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from pyxray.xray.models import DownloadKind, DownloadState
|
||||
|
||||
|
||||
class CoreConfigText(BaseModel):
|
||||
path: str
|
||||
content: str
|
||||
|
||||
|
||||
class AssetsDownloadRequest(BaseModel):
|
||||
xray_url: str = ""
|
||||
geo_url: str = ""
|
||||
geoip_url: str = ""
|
||||
geosite_url: str = ""
|
||||
force: bool = False
|
||||
proxy_url: str = ""
|
||||
|
||||
|
||||
class AssetsUpdateRequest(BaseModel):
|
||||
force: bool = False
|
||||
proxy_url: str = ""
|
||||
|
||||
|
||||
class AssetsTaskStartResponse(BaseModel):
|
||||
task_id: str
|
||||
state: DownloadState
|
||||
items: list[DownloadKind]
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
error: str
|
||||
@@ -0,0 +1,124 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from pyxray.assets.models import (
|
||||
AssetsDownloadRequest,
|
||||
AssetsTaskStartResponse,
|
||||
AssetsUpdateRequest,
|
||||
CoreConfigText,
|
||||
)
|
||||
from pyxray.libs.file_lock import FileLock, LockExistsError
|
||||
from pyxray.xray.manager import XrayManager
|
||||
from pyxray.xray.models import DownloadState, DownloadTaskStatus, XrayStatus
|
||||
from pyxray.xray.workflows import DownloadItem, build_download_items, select_update_items
|
||||
|
||||
|
||||
class AssetsTaskConflictError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class AssetsService:
|
||||
def __init__(self, manager: XrayManager):
|
||||
self.manager = manager
|
||||
self._thread: threading.Thread | None = None
|
||||
self._thread_lock = threading.Lock()
|
||||
|
||||
@classmethod
|
||||
def from_workdir(cls, workdir: str | Path | None = None) -> AssetsService:
|
||||
return cls(XrayManager.from_workdir(workdir))
|
||||
|
||||
def config_text(self) -> CoreConfigText:
|
||||
path = self.manager.paths.config_file
|
||||
return CoreConfigText(path=str(path), content=path.read_text(encoding="utf-8"))
|
||||
|
||||
def status(self) -> XrayStatus:
|
||||
return self.manager.status()
|
||||
|
||||
def download(self, request: AssetsDownloadRequest) -> AssetsTaskStartResponse:
|
||||
items = build_download_items(
|
||||
self.manager,
|
||||
xray_url=request.xray_url,
|
||||
geo_url=request.geo_url,
|
||||
geoip_url=request.geoip_url,
|
||||
geosite_url=request.geosite_url,
|
||||
)
|
||||
selected = [item for item in items if item.url]
|
||||
return self._start_items(selected, request.force, request.proxy_url)
|
||||
|
||||
def update(self, request: AssetsUpdateRequest) -> AssetsTaskStartResponse:
|
||||
selected = select_update_items(
|
||||
self.manager,
|
||||
force=request.force,
|
||||
proxy_url=request.proxy_url or None,
|
||||
)
|
||||
return self._start_items(selected, force=True, proxy_url=request.proxy_url)
|
||||
|
||||
def task_status(self) -> DownloadTaskStatus:
|
||||
return self.manager.download_status()
|
||||
|
||||
def cancel(self) -> DownloadTaskStatus:
|
||||
self.manager.paths.download_cancel_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.manager.paths.download_cancel_file.write_text("cancel", encoding="utf-8")
|
||||
return self.manager.cancel_download()
|
||||
|
||||
def _start_items(
|
||||
self,
|
||||
items: list[DownloadItem],
|
||||
force: bool,
|
||||
proxy_url: str,
|
||||
) -> AssetsTaskStartResponse:
|
||||
if not items:
|
||||
return AssetsTaskStartResponse(task_id="current", state=DownloadState.IDLE, items=[])
|
||||
|
||||
with self._thread_lock:
|
||||
if self._thread and self._thread.is_alive():
|
||||
raise AssetsTaskConflictError("another download is already running")
|
||||
|
||||
lock = FileLock(self.manager.paths.download_lock_file)
|
||||
try:
|
||||
lock.acquire()
|
||||
except LockExistsError as exc:
|
||||
raise AssetsTaskConflictError("another download is already running") from exc
|
||||
|
||||
self.manager.paths.download_cancel_file.unlink(missing_ok=True)
|
||||
self._thread = threading.Thread(
|
||||
target=self._run_items,
|
||||
args=(items, force, proxy_url or None, lock),
|
||||
daemon=True,
|
||||
)
|
||||
self._thread.start()
|
||||
|
||||
return AssetsTaskStartResponse(
|
||||
task_id="current",
|
||||
state=DownloadState.RUNNING,
|
||||
items=[_item_kind(item) for item in items],
|
||||
)
|
||||
|
||||
def _run_items(
|
||||
self,
|
||||
items: list[DownloadItem],
|
||||
force: bool,
|
||||
proxy_url: str | None,
|
||||
lock: FileLock,
|
||||
) -> None:
|
||||
try:
|
||||
for item in items:
|
||||
if item.exists and not force:
|
||||
continue
|
||||
item.run(item.url, proxy_url, self.manager.paths.download_cancel_file)
|
||||
status = self.manager.wait_download()
|
||||
if status.state != DownloadState.COMPLETED:
|
||||
break
|
||||
finally:
|
||||
self.manager.paths.download_cancel_file.unlink(missing_ok=True)
|
||||
lock.release()
|
||||
|
||||
|
||||
def _item_kind(item: DownloadItem):
|
||||
from pyxray.xray.models import DownloadKind
|
||||
|
||||
if item.name == "geo":
|
||||
return DownloadKind.GEO_BUNDLE
|
||||
return DownloadKind(item.name)
|
||||
@@ -0,0 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typer
|
||||
|
||||
from pyxray.cli.commands import config, download, update
|
||||
|
||||
app = typer.Typer(no_args_is_help=True, add_completion=False)
|
||||
app.command()(config)
|
||||
app.command()(download)
|
||||
app.command()(update)
|
||||
@@ -0,0 +1,69 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
import typer
|
||||
|
||||
from pyxray.cli.options import ForceOption, ProxyOption, WorkdirOption
|
||||
from pyxray.cli.runtime import request_cancel, run_download_items
|
||||
from pyxray.config import load_core_config
|
||||
from pyxray.xray.manager import XrayManager
|
||||
from pyxray.xray.workflows import build_download_items, select_update_items
|
||||
|
||||
|
||||
def config(workdir: WorkdirOption = None) -> None:
|
||||
"""Show core.toml configuration."""
|
||||
_, paths = load_core_config(workdir)
|
||||
typer.echo(paths.config_file.read_text(encoding="utf-8"), nl=False)
|
||||
|
||||
|
||||
def download(
|
||||
xray: Annotated[str, typer.Option("--xray", help="Download and extract Xray from URL.")] = "",
|
||||
geo: Annotated[
|
||||
str,
|
||||
typer.Option("--geo", help="Download and extract geo bundle from URL."),
|
||||
] = "",
|
||||
geo_ip: Annotated[str, typer.Option("--geo-ip", help="Download geoip.dat from URL.")] = "",
|
||||
geo_site: Annotated[
|
||||
str,
|
||||
typer.Option("--geo-site", help="Download geosite.dat from URL."),
|
||||
] = "",
|
||||
force: ForceOption = False,
|
||||
proxy: ProxyOption = None,
|
||||
workdir: WorkdirOption = None,
|
||||
cancel: Annotated[
|
||||
bool,
|
||||
typer.Option("--cancel", "-c", help="Request cancellation for the active download."),
|
||||
] = False,
|
||||
) -> None:
|
||||
"""Download Xray and geo files."""
|
||||
manager = XrayManager.from_workdir(workdir)
|
||||
if cancel:
|
||||
request_cancel(manager)
|
||||
raise typer.Exit()
|
||||
|
||||
items = build_download_items(manager, xray, geo, geo_ip, geo_site)
|
||||
selected = [item for item in items if item.url]
|
||||
if not selected:
|
||||
typer.echo("no download url configured")
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
run_download_items(manager, selected, force, proxy)
|
||||
|
||||
|
||||
def update(
|
||||
force: ForceOption = False,
|
||||
proxy: ProxyOption = None,
|
||||
workdir: WorkdirOption = None,
|
||||
) -> None:
|
||||
"""Update Xray and geo files when needed."""
|
||||
manager = XrayManager.from_workdir(workdir)
|
||||
selected = select_update_items(manager, force, proxy)
|
||||
|
||||
if not selected:
|
||||
status = manager.status()
|
||||
typer.echo(f"xray: {status.version or 'not installed'}")
|
||||
typer.echo("nothing to update")
|
||||
return
|
||||
|
||||
run_download_items(manager, selected, force=True, proxy=proxy)
|
||||
@@ -0,0 +1,22 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
import typer
|
||||
|
||||
WorkdirOption = Annotated[
|
||||
Path | None,
|
||||
typer.Option("--workdir", help="Use a custom workdir instead of the default data directory."),
|
||||
]
|
||||
ProxyOption = Annotated[
|
||||
str | None,
|
||||
typer.Option(
|
||||
"--proxy",
|
||||
help="Proxy URL used for downloading. Falls back to direct download on failure.",
|
||||
),
|
||||
]
|
||||
ForceOption = Annotated[
|
||||
bool,
|
||||
typer.Option("--force", "-f", help="Force download and replace existing files."),
|
||||
]
|
||||
@@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
|
||||
import typer
|
||||
|
||||
from pyxray.libs.file_lock import FileLock, LockExistsError
|
||||
from pyxray.xray.manager import XrayManager
|
||||
from pyxray.xray.models import DownloadState, DownloadTaskStatus
|
||||
from pyxray.xray.workflows import DownloadItem
|
||||
|
||||
|
||||
def request_cancel(manager: XrayManager, emit: Callable[[str], None] = typer.echo) -> None:
|
||||
manager.paths.download_cancel_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
manager.paths.download_cancel_file.write_text("cancel", encoding="utf-8")
|
||||
emit("cancel requested")
|
||||
|
||||
|
||||
def run_download_items(
|
||||
manager: XrayManager,
|
||||
items: list[DownloadItem],
|
||||
force: bool,
|
||||
proxy: str | None,
|
||||
emit: Callable[..., None] = typer.echo,
|
||||
) -> None:
|
||||
lock = FileLock(manager.paths.download_lock_file)
|
||||
try:
|
||||
lock.acquire()
|
||||
except LockExistsError:
|
||||
emit("another download is already running")
|
||||
raise typer.Exit(code=1) from None
|
||||
|
||||
manager.paths.download_cancel_file.unlink(missing_ok=True)
|
||||
try:
|
||||
for item in items:
|
||||
if item.exists and not force:
|
||||
emit(f"{item.name}: already exists, skipped")
|
||||
continue
|
||||
|
||||
emit(f"{item.name}: downloading")
|
||||
item.run(item.url, proxy, manager.paths.download_cancel_file)
|
||||
result = wait_with_progress(manager, emit)
|
||||
if result.state != DownloadState.COMPLETED:
|
||||
emit(f"{item.name}: {result.state}")
|
||||
if result.error:
|
||||
emit(result.error)
|
||||
raise typer.Exit(code=1)
|
||||
emit(f"{item.name}: completed")
|
||||
except KeyboardInterrupt:
|
||||
manager.cancel_download()
|
||||
emit("download canceled")
|
||||
raise typer.Exit(code=130) from None
|
||||
finally:
|
||||
manager.paths.download_cancel_file.unlink(missing_ok=True)
|
||||
lock.release()
|
||||
|
||||
|
||||
def wait_with_progress(
|
||||
manager: XrayManager,
|
||||
emit: Callable[..., None] = typer.echo,
|
||||
) -> DownloadTaskStatus:
|
||||
last_line = ""
|
||||
while True:
|
||||
status = manager.download_status()
|
||||
if status.state != DownloadState.RUNNING:
|
||||
emit("")
|
||||
return status
|
||||
|
||||
if status.total_bytes:
|
||||
percent = int((status.downloaded_bytes / status.total_bytes) * 100)
|
||||
line = f"\r{status.downloaded_bytes}/{status.total_bytes} bytes ({percent}%)"
|
||||
else:
|
||||
line = f"\r{status.downloaded_bytes} bytes"
|
||||
|
||||
if line != last_line:
|
||||
emit(line, nl=False)
|
||||
last_line = line
|
||||
time.sleep(0.2)
|
||||
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from pyxray.libs.paths import WorkdirPaths
|
||||
from pyxray.libs.toml_store import read_toml, write_toml
|
||||
|
||||
|
||||
class DownloadConfig(BaseModel):
|
||||
proxy_url: str = ""
|
||||
|
||||
|
||||
class XrayConfig(BaseModel):
|
||||
xray_url: str = ""
|
||||
geo_url: str = ""
|
||||
geoip_url: str = ""
|
||||
geosite_url: str = ""
|
||||
auto_update_interval: int = Field(default=0, ge=0)
|
||||
|
||||
|
||||
class CoreConfig(BaseModel):
|
||||
download: DownloadConfig = Field(default_factory=DownloadConfig)
|
||||
xray: XrayConfig = Field(default_factory=XrayConfig)
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: Path) -> CoreConfig:
|
||||
data = read_toml(path)
|
||||
return cls.model_validate(data) if data else cls()
|
||||
|
||||
def save(self, path: Path) -> None:
|
||||
write_toml(path, self.model_dump(mode="json"))
|
||||
|
||||
|
||||
def load_core_config(workdir: str | Path | None = None) -> tuple[CoreConfig, WorkdirPaths]:
|
||||
paths = WorkdirPaths.from_value(workdir)
|
||||
existed = paths.config_file.exists()
|
||||
config = CoreConfig.load(paths.config_file)
|
||||
paths.ensure()
|
||||
if not existed:
|
||||
config.save(paths.config_file)
|
||||
elif "workdir" in read_toml(paths.config_file):
|
||||
config.save(paths.config_file)
|
||||
return config, paths
|
||||
@@ -0,0 +1 @@
|
||||
"""Shared backend utilities."""
|
||||
@@ -0,0 +1,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class LockExistsError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FileLock:
|
||||
path: Path
|
||||
|
||||
def acquire(self) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
fd = os.open(str(self.path), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||||
except FileExistsError as exc:
|
||||
raise LockExistsError(str(self.path)) from exc
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as file:
|
||||
file.write(str(os.getpid()))
|
||||
|
||||
def release(self) -> None:
|
||||
self.path.unlink(missing_ok=True)
|
||||
|
||||
def exists(self) -> bool:
|
||||
return self.path.exists()
|
||||
@@ -0,0 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from loguru import logger
|
||||
|
||||
|
||||
def setup_logging(log_file: Path | None = None, level: str = "INFO") -> None:
|
||||
logger.remove()
|
||||
logger.add(sys.stderr, level=level)
|
||||
if log_file:
|
||||
log_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
logger.add(log_file, level=level, rotation="5 MB", retention=5)
|
||||
@@ -0,0 +1,52 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def default_workdir() -> Path:
|
||||
return Path.cwd() / "data"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkdirPaths:
|
||||
root: Path
|
||||
|
||||
@classmethod
|
||||
def from_value(cls, workdir: str | Path | None = None) -> WorkdirPaths:
|
||||
root = Path(workdir).expanduser() if workdir else default_workdir()
|
||||
return cls(root=root.resolve())
|
||||
|
||||
@property
|
||||
def config_file(self) -> Path:
|
||||
return self.root / "core.toml"
|
||||
|
||||
@property
|
||||
def logs_dir(self) -> Path:
|
||||
return self.root / "logs"
|
||||
|
||||
@property
|
||||
def temp_dir(self) -> Path:
|
||||
return self.root / "tmp"
|
||||
|
||||
@property
|
||||
def download_lock_file(self) -> Path:
|
||||
return self.temp_dir / "download.lock"
|
||||
|
||||
@property
|
||||
def download_cancel_file(self) -> Path:
|
||||
return self.temp_dir / "download.cancel"
|
||||
|
||||
@property
|
||||
def xray_dir(self) -> Path:
|
||||
return self.root / "xray"
|
||||
|
||||
@property
|
||||
def geo_dir(self) -> Path:
|
||||
return self.xray_dir
|
||||
|
||||
def ensure(self) -> None:
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
self.logs_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.xray_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -0,0 +1,69 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import platform
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class OSType(StrEnum):
|
||||
WINDOWS = "windows"
|
||||
LINUX = "linux"
|
||||
MACOS = "macos"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
class ArchType(StrEnum):
|
||||
AMD64 = "amd64"
|
||||
ARM64 = "arm64"
|
||||
X86 = "386"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
def current_os() -> OSType:
|
||||
system = platform.system().lower()
|
||||
if system == "windows":
|
||||
return OSType.WINDOWS
|
||||
if system == "linux":
|
||||
return OSType.LINUX
|
||||
if system == "darwin":
|
||||
return OSType.MACOS
|
||||
return OSType.UNKNOWN
|
||||
|
||||
|
||||
def current_arch() -> ArchType:
|
||||
machine = platform.machine().lower()
|
||||
if machine in {"x86_64", "amd64"}:
|
||||
return ArchType.AMD64
|
||||
if machine in {"aarch64", "arm64"}:
|
||||
return ArchType.ARM64
|
||||
if machine in {"x86", "i386", "i686"}:
|
||||
return ArchType.X86
|
||||
return ArchType.UNKNOWN
|
||||
|
||||
|
||||
def executable_name(name: str) -> str:
|
||||
if current_os() == OSType.WINDOWS and not name.endswith(".exe"):
|
||||
return f"{name}.exe"
|
||||
return name
|
||||
|
||||
|
||||
def xray_artifact_platform(os_type: OSType | None = None, arch: ArchType | None = None) -> str:
|
||||
os_type = os_type or current_os()
|
||||
arch = arch or current_arch()
|
||||
|
||||
os_part = {
|
||||
OSType.WINDOWS: "windows",
|
||||
OSType.LINUX: "linux",
|
||||
OSType.MACOS: "macos",
|
||||
}.get(os_type)
|
||||
|
||||
arch_part = {
|
||||
ArchType.AMD64: "64",
|
||||
ArchType.ARM64: "arm64-v8a",
|
||||
ArchType.X86: "32",
|
||||
}.get(arch)
|
||||
|
||||
if not os_part or not arch_part:
|
||||
msg = f"unsupported platform: os={os_type}, arch={arch}"
|
||||
raise ValueError(msg)
|
||||
|
||||
return f"Xray-{os_part}-{arch_part}"
|
||||
@@ -0,0 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import tomlkit
|
||||
|
||||
|
||||
def read_toml(path: Path) -> dict[str, Any]:
|
||||
if not path.exists():
|
||||
return {}
|
||||
with path.open("r", encoding="utf-8") as file:
|
||||
data = tomlkit.load(file)
|
||||
return dict(data)
|
||||
|
||||
|
||||
def write_toml(path: Path, data: dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
document = tomlkit.document()
|
||||
for key, value in data.items():
|
||||
document[key] = value
|
||||
with path.open("w", encoding="utf-8") as file:
|
||||
tomlkit.dump(document, file)
|
||||
@@ -0,0 +1 @@
|
||||
"""Xray runtime management."""
|
||||
@@ -0,0 +1,48 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import httpx
|
||||
|
||||
from pyxray.libs.sysinfo import xray_artifact_platform
|
||||
|
||||
OFFICIAL_RELEASE_BASE = "https://github.com/XTLS/Xray-core/releases/download"
|
||||
OFFICIAL_LATEST_RELEASE_API = "https://api.github.com/repos/XTLS/Xray-core/releases/latest"
|
||||
DEFAULT_VERSION = "v26.5.9"
|
||||
|
||||
_version_cache: str | None = None
|
||||
|
||||
|
||||
def default_archive_name() -> str:
|
||||
return f"{xray_artifact_platform()}.zip"
|
||||
|
||||
|
||||
def official_archive_url(version: str = DEFAULT_VERSION) -> str:
|
||||
return f"{OFFICIAL_RELEASE_BASE}/{version}/{default_archive_name()}"
|
||||
|
||||
|
||||
def latest_xray_version(timeout: float = 5.0) -> str:
|
||||
global _version_cache
|
||||
if _version_cache:
|
||||
return _version_cache
|
||||
|
||||
with httpx.Client(follow_redirects=True, timeout=timeout) as client:
|
||||
response = client.get(OFFICIAL_LATEST_RELEASE_API)
|
||||
response.raise_for_status()
|
||||
payload = json.loads(response.text)
|
||||
version = str(payload.get("tag_name") or "").strip()
|
||||
if not version:
|
||||
raise ValueError("latest Xray release response has no tag_name")
|
||||
_version_cache = version
|
||||
return version
|
||||
|
||||
|
||||
def default_xray_version(timeout: float = 5.0) -> str:
|
||||
try:
|
||||
return latest_xray_version(timeout)
|
||||
except Exception:
|
||||
return DEFAULT_VERSION
|
||||
|
||||
|
||||
def default_xray_url(timeout: float = 5.0) -> str:
|
||||
return official_archive_url(default_xray_version(timeout))
|
||||
@@ -0,0 +1,56 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from pyxray.libs.sysinfo import executable_name
|
||||
from pyxray.xray.models import FileStatus, XrayStatus
|
||||
|
||||
VERSION_RE = re.compile(r"Xray\s+(\S+)")
|
||||
|
||||
|
||||
def xray_executable_path(xray_dir: Path) -> Path:
|
||||
return xray_dir / executable_name("xray")
|
||||
|
||||
|
||||
def file_status(path: Path) -> FileStatus:
|
||||
return FileStatus(
|
||||
exists=path.exists(),
|
||||
path=str(path),
|
||||
size=path.stat().st_size if path.exists() else None,
|
||||
)
|
||||
|
||||
|
||||
def read_xray_version(executable: Path, timeout: float = 5.0) -> str:
|
||||
if not executable.exists():
|
||||
return ""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[str(executable), "version"],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return ""
|
||||
|
||||
output = f"{result.stdout}\n{result.stderr}"
|
||||
match = VERSION_RE.search(output)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return ""
|
||||
|
||||
|
||||
def inspect_xray(xray_dir: Path) -> XrayStatus:
|
||||
executable = xray_executable_path(xray_dir)
|
||||
version = read_xray_version(executable)
|
||||
return XrayStatus(
|
||||
installed=executable.exists(),
|
||||
executable_path=str(executable),
|
||||
version=version,
|
||||
healthy=bool(version),
|
||||
geoip=file_status(xray_dir / "geoip.dat"),
|
||||
geosite=file_status(xray_dir / "geosite.dat"),
|
||||
)
|
||||
@@ -0,0 +1,122 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from pyxray.xray.models import DownloadRequest, DownloadState, DownloadTaskStatus
|
||||
|
||||
|
||||
class DownloadCanceledError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class DownloadManager:
|
||||
def __init__(self, temp_dir: Path):
|
||||
self._temp_dir = temp_dir
|
||||
self._lock = threading.Lock()
|
||||
self._cancel = threading.Event()
|
||||
self._thread: threading.Thread | None = None
|
||||
self._status = DownloadTaskStatus()
|
||||
|
||||
def status(self) -> DownloadTaskStatus:
|
||||
with self._lock:
|
||||
return self._status.model_copy()
|
||||
|
||||
def start(self, request: DownloadRequest) -> DownloadTaskStatus:
|
||||
with self._lock:
|
||||
if self._thread and self._thread.is_alive():
|
||||
return self._status.model_copy()
|
||||
self._cancel.clear()
|
||||
self._status = DownloadTaskStatus(
|
||||
state=DownloadState.RUNNING,
|
||||
kind=request.kind,
|
||||
url=request.url,
|
||||
target=str(request.target),
|
||||
)
|
||||
self._thread = threading.Thread(target=self._run, args=(request,), daemon=True)
|
||||
self._thread.start()
|
||||
return self._status.model_copy()
|
||||
|
||||
def cancel(self) -> DownloadTaskStatus:
|
||||
self._cancel.set()
|
||||
return self.status()
|
||||
|
||||
def wait(self, interval: float = 0.2) -> DownloadTaskStatus:
|
||||
while self._thread and self._thread.is_alive():
|
||||
time.sleep(interval)
|
||||
return self.status()
|
||||
|
||||
def _set_status(self, **values: object) -> None:
|
||||
with self._lock:
|
||||
data = self._status.model_dump()
|
||||
data.update(values)
|
||||
self._status = DownloadTaskStatus.model_validate(data)
|
||||
|
||||
def _run(self, request: DownloadRequest) -> None:
|
||||
self._temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
temp_file = self._temp_dir / f"{request.kind}-{uuid.uuid4().hex}.download"
|
||||
try:
|
||||
self._download_with_fallback(request, temp_file)
|
||||
if self._cancel.is_set():
|
||||
raise DownloadCanceledError()
|
||||
self._install_download(temp_file, request)
|
||||
self._set_status(
|
||||
state=DownloadState.COMPLETED,
|
||||
downloaded_bytes=temp_file.stat().st_size,
|
||||
)
|
||||
except DownloadCanceledError:
|
||||
self._set_status(state=DownloadState.CANCELED)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("download failed")
|
||||
self._set_status(state=DownloadState.FAILED, error=str(exc))
|
||||
finally:
|
||||
temp_file.unlink(missing_ok=True)
|
||||
|
||||
def _download_with_fallback(self, request: DownloadRequest, temp_file: Path) -> None:
|
||||
if request.proxy_url:
|
||||
try:
|
||||
self._download(request, temp_file, request.proxy_url)
|
||||
return
|
||||
except Exception:
|
||||
temp_file.unlink(missing_ok=True)
|
||||
logger.warning("download with proxy failed, retrying without proxy")
|
||||
self._download(request, temp_file, "")
|
||||
|
||||
def _download(self, request: DownloadRequest, temp_file: Path, proxy_url: str) -> None:
|
||||
proxy = proxy_url or None
|
||||
with httpx.Client(proxy=proxy, follow_redirects=True, timeout=request.timeout) as client:
|
||||
with client.stream("GET", request.url) as response:
|
||||
response.raise_for_status()
|
||||
total = response.headers.get("content-length")
|
||||
self._set_status(total_bytes=int(total) if total else None, downloaded_bytes=0)
|
||||
downloaded = 0
|
||||
with temp_file.open("wb") as file:
|
||||
for chunk in response.iter_bytes():
|
||||
if self._cancel.is_set() or (
|
||||
request.cancel_file and request.cancel_file.exists()
|
||||
):
|
||||
raise DownloadCanceledError()
|
||||
if not chunk:
|
||||
continue
|
||||
file.write(chunk)
|
||||
downloaded += len(chunk)
|
||||
self._set_status(downloaded_bytes=downloaded)
|
||||
|
||||
def _install_download(self, temp_file: Path, request: DownloadRequest) -> None:
|
||||
request.target.parent.mkdir(parents=True, exist_ok=True)
|
||||
if request.extract:
|
||||
request.target.mkdir(parents=True, exist_ok=True)
|
||||
with zipfile.ZipFile(temp_file) as archive:
|
||||
archive.extractall(request.target)
|
||||
return
|
||||
|
||||
temp_target = request.target.with_suffix(f"{request.target.suffix}.tmp")
|
||||
shutil.copyfile(temp_file, temp_target)
|
||||
temp_target.replace(request.target)
|
||||
@@ -0,0 +1,153 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from pyxray.config import CoreConfig, load_core_config
|
||||
from pyxray.libs.paths import WorkdirPaths
|
||||
from pyxray.xray.defaults import default_xray_url
|
||||
from pyxray.xray.detector import inspect_xray
|
||||
from pyxray.xray.downloader import DownloadManager
|
||||
from pyxray.xray.models import DownloadKind, DownloadRequest, DownloadTaskStatus, XrayStatus
|
||||
from pyxray.xray.remote import version_from_path, version_from_url
|
||||
|
||||
_download_managers: dict[Path, DownloadManager] = {}
|
||||
_download_managers_lock = threading.Lock()
|
||||
|
||||
|
||||
def _download_manager_for(temp_dir: Path) -> DownloadManager:
|
||||
key = temp_dir.resolve()
|
||||
with _download_managers_lock:
|
||||
manager = _download_managers.get(key)
|
||||
if manager is None:
|
||||
manager = DownloadManager(key)
|
||||
_download_managers[key] = manager
|
||||
return manager
|
||||
|
||||
|
||||
class XrayManager:
|
||||
def __init__(self, config: CoreConfig, paths: WorkdirPaths):
|
||||
self.config = config
|
||||
self.paths = paths
|
||||
self.paths.ensure()
|
||||
self.downloads = _download_manager_for(paths.temp_dir)
|
||||
|
||||
@classmethod
|
||||
def from_workdir(cls, workdir: str | Path | None = None) -> XrayManager:
|
||||
config, paths = load_core_config(workdir)
|
||||
return cls(config, paths)
|
||||
|
||||
def status(self) -> XrayStatus:
|
||||
return inspect_xray(self.paths.xray_dir)
|
||||
|
||||
def effective_xray_url(self) -> str:
|
||||
return self.config.xray.xray_url or default_xray_url()
|
||||
|
||||
def remote_version(self, url: str = "", proxy_url: str | None = None):
|
||||
source = url or self.effective_xray_url()
|
||||
if not source:
|
||||
return None
|
||||
path = Path(source)
|
||||
if path.exists():
|
||||
return version_from_path(path)
|
||||
return version_from_url(
|
||||
source,
|
||||
proxy_url if proxy_url is not None else self.config.download.proxy_url,
|
||||
)
|
||||
|
||||
def download_xray(
|
||||
self,
|
||||
url: str = "",
|
||||
proxy_url: str | None = None,
|
||||
cancel_file: Path | None = None,
|
||||
) -> DownloadTaskStatus:
|
||||
source = url or self.effective_xray_url()
|
||||
if not source:
|
||||
raise ValueError("xray download url is empty")
|
||||
return self.downloads.start(
|
||||
DownloadRequest(
|
||||
kind=DownloadKind.XRAY,
|
||||
url=source,
|
||||
target=self.paths.xray_dir,
|
||||
extract=True,
|
||||
proxy_url=proxy_url if proxy_url is not None else self.config.download.proxy_url,
|
||||
cancel_file=cancel_file,
|
||||
)
|
||||
)
|
||||
|
||||
def download_geoip(
|
||||
self,
|
||||
url: str = "",
|
||||
proxy_url: str | None = None,
|
||||
cancel_file: Path | None = None,
|
||||
) -> DownloadTaskStatus:
|
||||
return self._download_geo(
|
||||
DownloadKind.GEOIP,
|
||||
url or self.config.xray.geoip_url,
|
||||
"geoip.dat",
|
||||
proxy_url,
|
||||
cancel_file,
|
||||
)
|
||||
|
||||
def download_geosite(
|
||||
self,
|
||||
url: str = "",
|
||||
proxy_url: str | None = None,
|
||||
cancel_file: Path | None = None,
|
||||
) -> DownloadTaskStatus:
|
||||
return self._download_geo(
|
||||
DownloadKind.GEOSITE,
|
||||
url or self.config.xray.geosite_url,
|
||||
"geosite.dat",
|
||||
proxy_url,
|
||||
cancel_file,
|
||||
)
|
||||
|
||||
def download_geo_bundle(
|
||||
self,
|
||||
url: str = "",
|
||||
proxy_url: str | None = None,
|
||||
cancel_file: Path | None = None,
|
||||
) -> DownloadTaskStatus:
|
||||
source = url or self.config.xray.geo_url
|
||||
if not source:
|
||||
raise ValueError("geo download url is empty")
|
||||
return self.downloads.start(
|
||||
DownloadRequest(
|
||||
kind=DownloadKind.GEO_BUNDLE,
|
||||
url=source,
|
||||
target=self.paths.xray_dir,
|
||||
extract=True,
|
||||
proxy_url=proxy_url if proxy_url is not None else self.config.download.proxy_url,
|
||||
cancel_file=cancel_file,
|
||||
)
|
||||
)
|
||||
|
||||
def download_status(self) -> DownloadTaskStatus:
|
||||
return self.downloads.status()
|
||||
|
||||
def cancel_download(self) -> DownloadTaskStatus:
|
||||
return self.downloads.cancel()
|
||||
|
||||
def wait_download(self) -> DownloadTaskStatus:
|
||||
return self.downloads.wait()
|
||||
|
||||
def _download_geo(
|
||||
self,
|
||||
kind: DownloadKind,
|
||||
source: str,
|
||||
filename: str,
|
||||
proxy_url: str | None = None,
|
||||
cancel_file: Path | None = None,
|
||||
) -> DownloadTaskStatus:
|
||||
if not source:
|
||||
raise ValueError(f"{kind} download url is empty")
|
||||
return self.downloads.start(
|
||||
DownloadRequest(
|
||||
kind=kind,
|
||||
url=source,
|
||||
target=self.paths.geo_dir / filename,
|
||||
proxy_url=proxy_url if proxy_url is not None else self.config.download.proxy_url,
|
||||
cancel_file=cancel_file,
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,68 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class DownloadKind(StrEnum):
|
||||
XRAY = "xray"
|
||||
GEOIP = "geoip"
|
||||
GEOSITE = "geosite"
|
||||
GEO_BUNDLE = "geo"
|
||||
|
||||
|
||||
class DownloadState(StrEnum):
|
||||
IDLE = "idle"
|
||||
RUNNING = "running"
|
||||
COMPLETED = "completed"
|
||||
CANCELED = "canceled"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class DownloadTaskStatus(BaseModel):
|
||||
state: DownloadState = DownloadState.IDLE
|
||||
kind: DownloadKind | None = None
|
||||
url: str = ""
|
||||
target: str = ""
|
||||
total_bytes: int | None = None
|
||||
downloaded_bytes: int = 0
|
||||
error: str = ""
|
||||
|
||||
@property
|
||||
def progress(self) -> float | None:
|
||||
if not self.total_bytes:
|
||||
return None
|
||||
return min(self.downloaded_bytes / self.total_bytes, 1.0)
|
||||
|
||||
|
||||
class FileStatus(BaseModel):
|
||||
exists: bool
|
||||
path: str
|
||||
size: int | None = None
|
||||
|
||||
|
||||
class XrayStatus(BaseModel):
|
||||
installed: bool
|
||||
executable_path: str
|
||||
version: str = ""
|
||||
healthy: bool = False
|
||||
geoip: FileStatus
|
||||
geosite: FileStatus
|
||||
|
||||
|
||||
class RemoteVersion(BaseModel):
|
||||
source: str
|
||||
version: str = ""
|
||||
download_url: str = ""
|
||||
|
||||
|
||||
class DownloadRequest(BaseModel):
|
||||
kind: DownloadKind
|
||||
url: str
|
||||
target: Path
|
||||
extract: bool = False
|
||||
proxy_url: str = ""
|
||||
cancel_file: Path | None = None
|
||||
timeout: float = Field(default=60.0, gt=0)
|
||||
@@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from pyxray.xray.models import RemoteVersion
|
||||
|
||||
VERSION_RE = re.compile(r"(?:v)?(\d+(?:\.\d+)+(?:[-+.\w]*)?)")
|
||||
|
||||
|
||||
def version_from_text(text: str) -> str:
|
||||
match = VERSION_RE.search(text)
|
||||
return match.group(1) if match else ""
|
||||
|
||||
|
||||
def version_from_path(path: Path) -> RemoteVersion:
|
||||
return RemoteVersion(source=str(path), version=version_from_text(path.name))
|
||||
|
||||
|
||||
def version_from_url(url: str, proxy_url: str = "", timeout: float = 20.0) -> RemoteVersion:
|
||||
proxy = proxy_url or None
|
||||
with httpx.Client(proxy=proxy, follow_redirects=True, timeout=timeout) as client:
|
||||
response = client.head(url)
|
||||
response.raise_for_status()
|
||||
final_url = str(response.url)
|
||||
return RemoteVersion(source=url, version=version_from_text(final_url), download_url=final_url)
|
||||
@@ -0,0 +1,96 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from pyxray.xray.manager import XrayManager
|
||||
from pyxray.xray.models import DownloadTaskStatus
|
||||
from pyxray.xray.remote import version_from_text
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DownloadItem:
|
||||
name: str
|
||||
url: str
|
||||
exists: bool
|
||||
run: Callable[[str, str | None, Path], DownloadTaskStatus]
|
||||
|
||||
|
||||
def build_download_items(
|
||||
manager: XrayManager,
|
||||
xray_url: str = "",
|
||||
geo_url: str = "",
|
||||
geoip_url: str = "",
|
||||
geosite_url: str = "",
|
||||
) -> list[DownloadItem]:
|
||||
status = manager.status()
|
||||
return [
|
||||
DownloadItem(
|
||||
name="xray",
|
||||
url=xray_url or manager.effective_xray_url(),
|
||||
exists=status.installed,
|
||||
run=manager.download_xray,
|
||||
),
|
||||
DownloadItem(
|
||||
name="geo",
|
||||
url=geo_url or manager.config.xray.geo_url,
|
||||
exists=status.geoip.exists and status.geosite.exists,
|
||||
run=manager.download_geo_bundle,
|
||||
),
|
||||
DownloadItem(
|
||||
name="geoip",
|
||||
url=geoip_url or manager.config.xray.geoip_url,
|
||||
exists=status.geoip.exists,
|
||||
run=manager.download_geoip,
|
||||
),
|
||||
DownloadItem(
|
||||
name="geosite",
|
||||
url=geosite_url or manager.config.xray.geosite_url,
|
||||
exists=status.geosite.exists,
|
||||
run=manager.download_geosite,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def select_update_items(
|
||||
manager: XrayManager,
|
||||
force: bool,
|
||||
proxy_url: str | None,
|
||||
) -> list[DownloadItem]:
|
||||
items = build_download_items(manager)
|
||||
selected: list[DownloadItem] = []
|
||||
|
||||
xray_item = next(item for item in items if item.name == "xray")
|
||||
if xray_item.url and should_update_xray(manager, force, proxy_url):
|
||||
selected.append(xray_item)
|
||||
|
||||
for item in items:
|
||||
if item.name == "xray" or not item.url:
|
||||
continue
|
||||
if force or not item.exists:
|
||||
selected.append(item)
|
||||
|
||||
return selected
|
||||
|
||||
|
||||
def should_update_xray(manager: XrayManager, force: bool, proxy_url: str | None) -> bool:
|
||||
if force:
|
||||
return True
|
||||
|
||||
local = manager.status()
|
||||
if not local.installed:
|
||||
return True
|
||||
|
||||
remote = manager.remote_version(proxy_url=proxy_url)
|
||||
if remote is None or not remote.version or not local.version:
|
||||
return False
|
||||
|
||||
return version_tuple(remote.version) > version_tuple(local.version)
|
||||
|
||||
|
||||
def version_tuple(value: str) -> tuple[int, ...]:
|
||||
version = version_from_text(value)
|
||||
if not version:
|
||||
return ()
|
||||
return tuple(int(part) for part in version.split(".") if part.isdigit())
|
||||
@@ -0,0 +1,79 @@
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from pyxray.api.app import create_app
|
||||
|
||||
|
||||
def test_assets_config_returns_core_toml_text(tmp_path) -> None:
|
||||
client = TestClient(create_app(tmp_path))
|
||||
|
||||
response = client.get("/api/v1/assets/config")
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["path"].endswith("core.toml")
|
||||
assert "[download]" in payload["content"]
|
||||
assert "workdir" not in payload["content"]
|
||||
|
||||
|
||||
def test_assets_status_reports_missing_assets(tmp_path) -> None:
|
||||
client = TestClient(create_app(tmp_path))
|
||||
|
||||
response = client.get("/api/v1/assets/status")
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["installed"] is False
|
||||
assert payload["geoip"]["exists"] is False
|
||||
assert payload["geosite"]["exists"] is False
|
||||
|
||||
|
||||
def test_assets_download_without_urls_starts_default_xray_task(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
"pyxray.xray.manager.default_xray_url",
|
||||
lambda: "https://default.invalid/xray.zip",
|
||||
)
|
||||
client = TestClient(create_app(tmp_path))
|
||||
|
||||
response = client.post("/api/v1/assets/download", json={"force": True})
|
||||
|
||||
assert response.status_code == 202
|
||||
payload = response.json()
|
||||
assert payload["task_id"] == "current"
|
||||
assert payload["state"] == "running"
|
||||
assert payload["items"] == ["xray"]
|
||||
|
||||
|
||||
def test_assets_download_rejects_when_lock_exists(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
"pyxray.xray.manager.default_xray_url",
|
||||
lambda: "https://default.invalid/xray.zip",
|
||||
)
|
||||
app = create_app(tmp_path)
|
||||
service = app.state.assets_service
|
||||
service.manager.paths.ensure()
|
||||
service.manager.paths.download_lock_file.write_text("locked", encoding="utf-8")
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post("/api/v1/assets/download", json={"force": True})
|
||||
|
||||
assert response.status_code == 409
|
||||
assert response.json() == {"error": "another download is already running"}
|
||||
|
||||
|
||||
def test_assets_download_task_returns_status(tmp_path) -> None:
|
||||
client = TestClient(create_app(tmp_path))
|
||||
|
||||
response = client.get("/api/v1/assets/download/task")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["state"] == "idle"
|
||||
|
||||
|
||||
def test_assets_cancel_writes_cancel_signal(tmp_path) -> None:
|
||||
app = create_app(tmp_path)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post("/api/v1/assets/download/cancel")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert app.state.assets_service.manager.paths.download_cancel_file.exists()
|
||||
@@ -0,0 +1,14 @@
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from pyxray.cli import app
|
||||
|
||||
|
||||
def test_config_command_shows_core_config(tmp_path) -> None:
|
||||
runner = CliRunner()
|
||||
|
||||
result = runner.invoke(app, ["config", "--workdir", str(tmp_path)])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "xray_url = " in result.output
|
||||
assert "effective_xray_url:" not in result.output
|
||||
assert (tmp_path / "core.toml").exists()
|
||||
@@ -0,0 +1,39 @@
|
||||
import pytest
|
||||
import typer
|
||||
|
||||
from pyxray.cli.runtime import request_cancel, run_download_items
|
||||
from pyxray.config import CoreConfig
|
||||
from pyxray.libs.paths import WorkdirPaths
|
||||
from pyxray.xray.downloader import DownloadManager
|
||||
from pyxray.xray.manager import XrayManager
|
||||
|
||||
|
||||
def make_manager(tmp_path) -> XrayManager:
|
||||
return XrayManager(CoreConfig(), WorkdirPaths.from_value(tmp_path))
|
||||
|
||||
|
||||
def test_request_cancel_writes_cancel_file(tmp_path) -> None:
|
||||
manager = make_manager(tmp_path)
|
||||
|
||||
request_cancel(manager, emit=lambda message: None)
|
||||
|
||||
assert manager.paths.download_cancel_file.exists()
|
||||
|
||||
|
||||
def test_run_download_items_rejects_existing_lock(tmp_path) -> None:
|
||||
manager = make_manager(tmp_path)
|
||||
manager.paths.ensure()
|
||||
manager.paths.download_lock_file.write_text("locked", encoding="utf-8")
|
||||
|
||||
with pytest.raises(typer.Exit) as exc:
|
||||
run_download_items(manager, [], force=False, proxy=None, emit=lambda *args, **kwargs: None)
|
||||
|
||||
assert exc.value.exit_code == 1
|
||||
|
||||
|
||||
def test_xray_manager_reuses_download_manager_for_workdir(tmp_path) -> None:
|
||||
first = make_manager(tmp_path)
|
||||
second = make_manager(tmp_path)
|
||||
|
||||
assert isinstance(first.downloads, DownloadManager)
|
||||
assert first.downloads is second.downloads
|
||||
@@ -0,0 +1,35 @@
|
||||
from pyxray.config import CoreConfig, load_core_config
|
||||
|
||||
|
||||
def test_core_config_defaults_are_empty() -> None:
|
||||
config = CoreConfig()
|
||||
|
||||
assert config.xray.xray_url == ""
|
||||
assert config.xray.geo_url == ""
|
||||
assert config.xray.geoip_url == ""
|
||||
assert config.xray.geosite_url == ""
|
||||
assert config.xray.auto_update_interval == 0
|
||||
assert config.download.proxy_url == ""
|
||||
|
||||
|
||||
def test_load_core_config_creates_core_toml(tmp_path) -> None:
|
||||
config, paths = load_core_config(tmp_path)
|
||||
|
||||
assert config.xray.auto_update_interval == 0
|
||||
assert paths.config_file == tmp_path / "core.toml"
|
||||
assert paths.config_file.exists()
|
||||
assert paths.xray_dir.exists()
|
||||
assert paths.temp_dir.exists()
|
||||
assert "workdir" not in paths.config_file.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_load_core_config_removes_deprecated_workdir(tmp_path) -> None:
|
||||
config_file = tmp_path / "core.toml"
|
||||
config_file.write_text(
|
||||
'workdir = "old"\n\n[download]\nproxy_url = ""\n\n[xray]\nxray_url = ""\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
_, paths = load_core_config(tmp_path)
|
||||
|
||||
assert "workdir" not in paths.config_file.read_text(encoding="utf-8")
|
||||
@@ -0,0 +1,19 @@
|
||||
from pathlib import Path
|
||||
|
||||
from pyxray.xray.downloader import DownloadManager
|
||||
from pyxray.xray.models import DownloadKind, DownloadRequest, DownloadState
|
||||
|
||||
|
||||
def test_download_manager_starts_single_running_task(tmp_path) -> None:
|
||||
manager = DownloadManager(tmp_path)
|
||||
request = DownloadRequest(
|
||||
kind=DownloadKind.GEOIP,
|
||||
url="https://example.invalid/geoip.dat",
|
||||
target=Path(tmp_path / "geoip.dat"),
|
||||
)
|
||||
|
||||
first = manager.start(request)
|
||||
second = manager.start(request)
|
||||
|
||||
assert first.state == DownloadState.RUNNING
|
||||
assert second.kind == DownloadKind.GEOIP
|
||||
@@ -0,0 +1,14 @@
|
||||
from pyxray.libs.sysinfo import ArchType, OSType, executable_name, xray_artifact_platform
|
||||
|
||||
|
||||
def test_xray_artifact_platform_windows_amd64() -> None:
|
||||
assert xray_artifact_platform(OSType.WINDOWS, ArchType.AMD64) == "Xray-windows-64"
|
||||
|
||||
|
||||
def test_xray_artifact_platform_linux_arm64() -> None:
|
||||
assert xray_artifact_platform(OSType.LINUX, ArchType.ARM64) == "Xray-linux-arm64-v8a"
|
||||
|
||||
|
||||
def test_executable_name_has_platform_suffix() -> None:
|
||||
name = executable_name("xray")
|
||||
assert name in {"xray", "xray.exe"}
|
||||
@@ -0,0 +1,134 @@
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from pyxray.config import CoreConfig
|
||||
from pyxray.libs.paths import WorkdirPaths
|
||||
from pyxray.xray.models import FileStatus, RemoteVersion, XrayStatus
|
||||
from pyxray.xray.workflows import build_download_items, select_update_items, version_tuple
|
||||
|
||||
|
||||
class FakeManager(BaseModel):
|
||||
config: CoreConfig
|
||||
paths: WorkdirPaths
|
||||
local_status: XrayStatus
|
||||
remote: RemoteVersion | None = None
|
||||
|
||||
model_config = {"arbitrary_types_allowed": True}
|
||||
|
||||
def status(self) -> XrayStatus:
|
||||
return self.local_status
|
||||
|
||||
def effective_xray_url(self) -> str:
|
||||
return self.config.xray.xray_url or "https://default.invalid/xray.zip"
|
||||
|
||||
def remote_version(self, proxy_url: str | None = None) -> RemoteVersion | None:
|
||||
return self.remote
|
||||
|
||||
def download_xray(self, url: str, proxy_url: str | None, cancel_file: Path):
|
||||
raise NotImplementedError
|
||||
|
||||
def download_geo_bundle(self, url: str, proxy_url: str | None, cancel_file: Path):
|
||||
raise NotImplementedError
|
||||
|
||||
def download_geoip(self, url: str, proxy_url: str | None, cancel_file: Path):
|
||||
raise NotImplementedError
|
||||
|
||||
def download_geosite(self, url: str, proxy_url: str | None, cancel_file: Path):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def make_manager(
|
||||
tmp_path,
|
||||
*,
|
||||
installed: bool = False,
|
||||
version: str = "",
|
||||
geoip: bool = False,
|
||||
geosite: bool = False,
|
||||
remote_version: str = "",
|
||||
) -> FakeManager:
|
||||
config = CoreConfig.model_validate(
|
||||
{
|
||||
"xray": {
|
||||
"xray_url": "https://example.invalid/xray.zip",
|
||||
"geo_url": "https://example.invalid/geo.zip",
|
||||
"geoip_url": "https://example.invalid/geoip.dat",
|
||||
"geosite_url": "https://example.invalid/geosite.dat",
|
||||
}
|
||||
}
|
||||
)
|
||||
status = XrayStatus(
|
||||
installed=installed,
|
||||
executable_path=str(tmp_path / "xray.exe"),
|
||||
version=version,
|
||||
healthy=bool(version),
|
||||
geoip=FileStatus(exists=geoip, path=str(tmp_path / "geoip.dat")),
|
||||
geosite=FileStatus(exists=geosite, path=str(tmp_path / "geosite.dat")),
|
||||
)
|
||||
remote = (
|
||||
RemoteVersion(source="test", version=remote_version)
|
||||
if remote_version
|
||||
else None
|
||||
)
|
||||
return FakeManager(
|
||||
config=config,
|
||||
paths=WorkdirPaths.from_value(tmp_path),
|
||||
local_status=status,
|
||||
remote=remote,
|
||||
)
|
||||
|
||||
|
||||
def test_build_download_items_uses_explicit_urls(tmp_path) -> None:
|
||||
manager = make_manager(tmp_path)
|
||||
|
||||
items = build_download_items(manager, xray_url="https://override.invalid/xray.zip")
|
||||
|
||||
assert items[0].name == "xray"
|
||||
assert items[0].url == "https://override.invalid/xray.zip"
|
||||
assert items[1].name == "geo"
|
||||
|
||||
|
||||
def test_build_download_items_uses_default_xray_url_when_config_is_empty(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
manager = make_manager(tmp_path)
|
||||
manager.config.xray.xray_url = ""
|
||||
|
||||
items = build_download_items(manager)
|
||||
|
||||
assert items[0].url == "https://default.invalid/xray.zip"
|
||||
|
||||
|
||||
def test_update_selects_missing_xray_and_geo_files(tmp_path) -> None:
|
||||
manager = make_manager(tmp_path, installed=False, geoip=False, geosite=False)
|
||||
|
||||
selected = select_update_items(manager, force=False, proxy_url=None)
|
||||
|
||||
assert [item.name for item in selected] == ["xray", "geo", "geoip", "geosite"]
|
||||
|
||||
|
||||
def test_update_selects_newer_remote_xray(tmp_path) -> None:
|
||||
manager = make_manager(
|
||||
tmp_path,
|
||||
installed=True,
|
||||
version="1.0.0",
|
||||
geoip=True,
|
||||
geosite=True,
|
||||
remote_version="1.1.0",
|
||||
)
|
||||
|
||||
selected = select_update_items(manager, force=False, proxy_url=None)
|
||||
|
||||
assert [item.name for item in selected] == ["xray"]
|
||||
|
||||
|
||||
def test_force_update_selects_every_configured_item(tmp_path) -> None:
|
||||
manager = make_manager(tmp_path, installed=True, version="1.0.0", geoip=True, geosite=True)
|
||||
|
||||
selected = select_update_items(manager, force=True, proxy_url=None)
|
||||
|
||||
assert [item.name for item in selected] == ["xray", "geo", "geoip", "geosite"]
|
||||
|
||||
|
||||
def test_version_tuple_extracts_semver() -> None:
|
||||
assert version_tuple("v25.3.6") == (25, 3, 6)
|
||||
Generated
+466
@@ -0,0 +1,466 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.12"
|
||||
|
||||
[[package]]
|
||||
name = "annotated-doc"
|
||||
version = "0.0.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "annotated-types"
|
||||
version = "0.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.13.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "idna" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.5.20"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" }
|
||||
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 = "click"
|
||||
version = "8.4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" }
|
||||
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 = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.136.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-doc" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "starlette" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/81/2d/ff8d91d7b564d464629a0fd50a4489c97fcb836ac230bf3a7269232a9b1f/fastapi-0.136.3.tar.gz", hash = "sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab", size = 396410, upload-time = "2026-05-23T18:53:15.192Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload-time = "2026-05-23T18:53:16.924Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpcore"
|
||||
version = "1.0.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx"
|
||||
version = "0.28.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "certifi" },
|
||||
{ name = "httpcore" },
|
||||
{ name = "idna" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.16"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1a/88/bcf9709822fe69d02c2a6a77956c98ce6ea8ca8767a9aadcedc7eb6a2390/idna-3.16.tar.gz", hash = "sha256:d7a6da03db833450fca25d2358ac9ff06cd624577a4aea3a596d5c0f77b8e03d", size = 203770, upload-time = "2026-05-22T00:16:18.781Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/94/16/70255075a9859a0e3adb789b68ceb0e210dec03934245fd98d248226572f/idna-3.16-py3-none-any.whl", hash = "sha256:cc246e3a3f89580c3a951b5ad298ca4638078b2cdd4f115654332b5c26daded5", size = 74165, upload-time = "2026-05-22T00:16:16.698Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "loguru"
|
||||
version = "0.7.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "win32-setctime", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markdown-it-py"
|
||||
version = "4.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "mdurl" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mdurl"
|
||||
version = "0.1.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||
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 = "pydantic"
|
||||
version = "2.13.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-types" },
|
||||
{ name = "pydantic-core" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.46.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.20.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
|
||||
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 = "pytest"
|
||||
version = "9.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyxray-backend"
|
||||
version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "fastapi" },
|
||||
{ name = "httpx" },
|
||||
{ name = "loguru" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "tomlkit" },
|
||||
{ name = "typer" },
|
||||
{ name = "uvicorn" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "pytest" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "fastapi", specifier = ">=0.116.0" },
|
||||
{ name = "httpx", specifier = ">=0.28.1" },
|
||||
{ name = "loguru", specifier = ">=0.7.3" },
|
||||
{ name = "pydantic", specifier = ">=2.11.0" },
|
||||
{ name = "tomlkit", specifier = ">=0.13.3" },
|
||||
{ name = "typer", specifier = ">=0.16.0" },
|
||||
{ name = "uvicorn", specifier = ">=0.35.0" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "pytest", specifier = ">=8.3.0" },
|
||||
{ name = "ruff", specifier = ">=0.11.0" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rich"
|
||||
version = "15.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markdown-it-py" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.14"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/dc/8a/8bce2894573e9dae6ff4d77fe34ad727d79b9e6238ad288c5638990d90f6/ruff-0.15.14.tar.gz", hash = "sha256:48e866b165be4a9bdbf310f7d3c9a07edef2fe8cd63ffeb4e00bb590506ebf9f", size = 4700910, upload-time = "2026-05-21T14:34:55.177Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/c8/74a92c6ff9fcfb4f1f947126d3ebee8389276e161ecc85de5bda7cda51bd/ruff-0.15.14-py3-none-linux_armv6l.whl", hash = "sha256:8dd2db9416e487c8d4b01fa7056bb02c4d05969d4f8d17a08c229c2f4ff3c108", size = 10739177, upload-time = "2026-05-21T14:34:37.332Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/91/254a35c20acc38a7223c9d2d594af12e794432464f2cdeb52af1dc4a892d/ruff-0.15.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:be4ff55af755bd71a00ab3dc6bd7ffc467bd76e0df6881e286c2e3d23e8fb43b", size = 11144969, upload-time = "2026-05-21T14:34:43.978Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/9e/d13e40f83b8d0a94430e6778ce1d94a43b38cf2efe63278bdd2b4c65abbf/ruff-0.15.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:48d5909d7d06276ce7dde6d32bfa4b0d4cb2651145cd8ee4b440722cbc77832f", size = 10478207, upload-time = "2026-05-21T14:34:48.378Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/f1/b15a7839fa4f332f8acec78e20564f26bb2d866e3d21710b877fd0263000/ruff-0.15.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca8cbfa94c4f90984a67561978602746d4cd27103568f745fa90eee3f0d4107d", size = 10818459, upload-time = "2026-05-21T14:34:22.318Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/33/53d651177f84f94b400a0e27f8824eeada3dddc9d5ee8aeb048f4352a520/ruff-0.15.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a6bbc0333f1ab053423bcbf6226477d266ca7cec7738c4c8e3f55647803f3c4", size = 10541800, upload-time = "2026-05-21T14:34:20.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/a6/868f87e0bf9786ed24b5d0d0ad8676b8a94fd1912f42cddf9cfc7857818a/ruff-0.15.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a24a4f7605d7003a6674d4387651effd939dead3fddd0f36561eb77a9a2e542", size = 11342149, upload-time = "2026-05-21T14:34:46.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/8b/38cd5c19faffdcc05a408d2b78edccc69492ab9720eadb49ea15ef80d768/ruff-0.15.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:049b5326e53ed80978f2fc041a280603f69dd6b0c95464342a2bb4572d9d9e2f", size = 12212563, upload-time = "2026-05-21T14:34:28.579Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/4d/a3c5b874a556d5731e3e657aaf04311bb76f0a5c3ec220ed43051be6b64b/ruff-0.15.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4ed42e6696c8dfa5f06728e6441993901f548eb92d73bc472cb5a38d1395fbf", size = 11493299, upload-time = "2026-05-21T14:34:41.836Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/c0/56472c251d09858a53e51efbd485b09e1995d8731668b76d52e5dd6ee0f1/ruff-0.15.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:715c543cf450c4888251f91c52f1942a800541d9bddd7ac060aa4e6b77ae7cba", size = 11455931, upload-time = "2026-05-21T14:34:57.276Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/4a/e2e7b4d8dbf233d4eace59c75bc3435fa6d8bd3bae82d351d4e4300c0fd1/ruff-0.15.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ebab6013ec887d439d8b7593737a0a4ffb06d45d209d4e4bf2e92813082d3f", size = 11400794, upload-time = "2026-05-21T14:34:39.773Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/c7/83c0539fe34c3e09136204d1e75d6052492364e0b3cb05e9465423f567d7/ruff-0.15.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:49072d36abdbe97a8dd7f480afe9c675699c0c495d4c84076e2c1203c4550581", size = 10804759, upload-time = "2026-05-21T14:34:31.045Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/a6/18f2bfc095a2ab4a78745644e428205532ce6653a5d0fa8501572891534d/ruff-0.15.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:958522aee105068640c2c2ceae08f413ae44d922f52a1374ac13d6a96032fc93", size = 10539517, upload-time = "2026-05-21T14:34:53.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/3a/5a8b3b69c654d4e4bf1d246ac5b49cbcdac6eaab6905925f8915f31e3b80/ruff-0.15.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f3707da619a143a2e8830e2abab8224478d69ace2d28cb6c20543ae97c36bf61", size = 11065169, upload-time = "2026-05-21T14:34:24.484Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/c5/8864e4e7925b836ea354b31d57641ec03830564e281a8b6f061f8c3e0ec1/ruff-0.15.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:bb01d645694e3ec0102105d07ef2d53703970407d59c04e59d3ba0b7a1d53553", size = 11560214, upload-time = "2026-05-21T14:34:50.975Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/38/012bf76752e1f89ed50b77b99532d90f3a3e287bc7918e1fc0948ac866ac/ruff-0.15.14-py3-none-win32.whl", hash = "sha256:6d0c1ad2a0ab718d39b6d8fd2217981ce4d625cd96a720095f798fb47d8b13e6", size = 10805548, upload-time = "2026-05-21T14:34:33.453Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/b7/4ea2c170f10ad760fff2a5250beb18897719dc8b52b53a24cddbb9dd3f19/ruff-0.15.14-py3-none-win_amd64.whl", hash = "sha256:802342981e056db3851a7836e5b070f8f15f67d4a685ae2a6160939d364b2902", size = 11939523, upload-time = "2026-05-21T14:34:18.077Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/d5/bc97ff895ec35cf3925d4bd60f3b39d822f377a446906ec9bcc87405e59b/ruff-0.15.14-py3-none-win_arm64.whl", hash = "sha256:ff47b90a9ef6a40c9e2f3b479c1fb78531adf055b94c1eba0a7ba04b31951826", size = 11208607, upload-time = "2026-05-21T14:34:26.525Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shellingham"
|
||||
version = "1.5.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "starlette"
|
||||
version = "1.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/95/66/4d20cdf39a8d6a51e663b7038e3b828ff211d3891a43a713fe7e4643f3a8/starlette-1.1.0.tar.gz", hash = "sha256:e83c7fe0ddecd8719c5b840080325aec0260acec86e9832899e377b91d65e90f", size = 2660060, upload-time = "2026-05-23T16:55:41.376Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/93/79/920b8e0a8b20f793e8d64855095cb8febabf6175b8550b6f7a547d813891/starlette-1.1.0-py3-none-any.whl", hash = "sha256:7f0dfd38e428aad5cb6f9f667f0ca1d2d8ca3f3385dccac8305f79ec98458382", size = 72899, upload-time = "2026-05-23T16:55:39.201Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tomlkit"
|
||||
version = "0.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/51/db/03eaf4331631ef6b27d6e3c9b68c54dc6f0d63d87201fed600cc409307fd/tomlkit-0.15.0.tar.gz", hash = "sha256:7d1a9ecba3086638211b13814ea79c90dd54dd11993564376f3aa92271f5c7a3", size = 161875, upload-time = "2026-05-10T07:38:22.245Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/43/8bd850ee71a191bf072e31302c73a66be413fecdd98fdcd111ecbcce13ca/tomlkit-0.15.0-py3-none-any.whl", hash = "sha256:4dbc8f0fc024412b57ced8757ac7461305126a648ff8c2c807fcb8e133a78738", size = 41328, upload-time = "2026-05-10T07:38:23.517Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typer"
|
||||
version = "0.26.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-doc" },
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "rich" },
|
||||
{ name = "shellingham" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/67/a5/756f2e6bc81a7dd79aa3c625dd01b74cabc4516628cace2caaec09ca6ff2/typer-0.26.2.tar.gz", hash = "sha256:9b4f19e08fcc9427a822d1ef467b1fe76737a2f65c7926bdeba2337d73569b68", size = 198991, upload-time = "2026-05-27T10:41:39.166Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/a5/6ffd702beda8798b2b82ff70805ed4a66d963557e43a5d1823ab456251a4/typer-0.26.2-py3-none-any.whl", hash = "sha256:39beff72ffbb31978a5b545f677d57edb97c6f980f433b38556deb0af25f094d", size = 123123, upload-time = "2026-05-27T10:41:40.504Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-inspection"
|
||||
version = "0.4.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uvicorn"
|
||||
version = "0.48.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e6/bf/f6544ba992ddb9a6077343a576f9844f7f8f06ab819aefd00206e9255f18/uvicorn-0.48.0.tar.gz", hash = "sha256:a5504207195d08c2511bf9125ede5ac4a4b71725d519e758d01dcf0bc2d31c37", size = 91074, upload-time = "2026-05-24T12:08:41.925Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/01/be/72532be3da7acc5fdfbccdb95215cd04f995a0886532a5b423f929cda4cc/uvicorn-0.48.0-py3-none-any.whl", hash = "sha256:48097851328b87ec36117d3d575234519eb58c2b22d79666e9bbc6c49a761dad", size = 71410, upload-time = "2026-05-24T12:08:40.258Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "win32-setctime"
|
||||
version = "1.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" },
|
||||
]
|
||||
@@ -1,16 +0,0 @@
|
||||
services:
|
||||
pyxray:
|
||||
image: docker.pchuan.top/pyxray:latest
|
||||
container_name: pyxray
|
||||
restart: unless-stopped
|
||||
privileged: true
|
||||
network_mode: host
|
||||
environment:
|
||||
PYXRAY_HOST: 127.0.0.1
|
||||
PYXRAY_PORT: 13999
|
||||
PYXRAY_XRAY_DIR: /config/xray
|
||||
volumes:
|
||||
- ./data:/config
|
||||
- /lib/modules:/lib/modules:ro
|
||||
- /etc/resolv.conf:/etc/resolv.conf
|
||||
stop_grace_period: 15s
|
||||
@@ -1,53 +0,0 @@
|
||||
# pyxray 配置总览
|
||||
|
||||
配置来源分两类:
|
||||
|
||||
| 文件 | 数据结构 | 入口 | 作用 |
|
||||
| --- | --- | --- | --- |
|
||||
| `settings.toml` | `XrayConfigSettings` | 配置页 | 生成 `config.json`、透明代理脚本和 `tinytun.yaml`。 |
|
||||
| `download.toml` | `XrayAssetSettings` | 下载页 | 保存 Xray 资源下载参数。 |
|
||||
|
||||
UI 中有些字段隐藏但仍存在于 `settings.toml`。下表的“UI”列含义:
|
||||
|
||||
| UI | 含义 |
|
||||
| --- | --- |
|
||||
| 显示 | Web 配置页可直接修改。 |
|
||||
| 隐藏 | Web 不显示,但保存时会写入默认值或保留已有值。 |
|
||||
| 下载页 | 不属于配置页,在下载页显示。 |
|
||||
|
||||
## 分类
|
||||
|
||||
| 分类 | 文档 | 主要影响 |
|
||||
| --- | --- | --- |
|
||||
| 核心 | [config/core.md](config/core.md) | Xray 日志、Mux、TCP Fast Open。 |
|
||||
| 入站 | [config/inbounds.md](config/inbounds.md) | SOCKS/HTTP/rule/API/自定义入站。 |
|
||||
| 路由 | [config/routing.md](config/routing.md) | rule 入站和透明代理流量的分流策略。 |
|
||||
| DNS | [config/dns.md](config/dns.md) | Xray DNS 模块、本地 DNS 入站、DNS 服务器路由。 |
|
||||
| 透明代理 | [config/transparent.md](config/transparent.md) | transparent inbound、iptables/nft、resolv、TinyTun。 |
|
||||
| 出站和自动更新 | [config/outbounds-auto-update.md](config/outbounds-auto-update.md) | 出站组预留字段、自动更新预留字段。 |
|
||||
| 资源下载 | [config/assets.md](config/assets.md) | `xray`、`geoip.dat`、`geosite.dat` 下载设置。 |
|
||||
|
||||
## 配置生成结果
|
||||
|
||||
| 输出 | 触发 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `config.json` | 生成配置/启动 Xray | Xray 主配置。 |
|
||||
| `transparent/ip-forward-apply.sh` | 生成配置/启动 Xray | 写 `/proc/sys/net/...`。 |
|
||||
| `transparent/resolv-hijack-setup.sh` | 生成配置/启动 Xray | redirect DNS 劫持时改 `/etc/resolv.conf`。 |
|
||||
| `transparent/resolv-hijack-cleanup.sh` | 停止/回滚 | 恢复 DNS。 |
|
||||
| `transparent/transparent-iptables-*.sh` | 生成配置/启动/停止 | iptables 规则安装和清理。 |
|
||||
| `transparent/transparent-nft-*.sh` | 生成配置/启动/停止 | nftables 规则安装和清理。 |
|
||||
| `transparent/v2raya.nft` | nft 模式 | nftables 表内容。 |
|
||||
| `transparent/tinytun.yaml` | `transparent.type = tun` | TinyTun 配置。 |
|
||||
|
||||
## 关键默认值
|
||||
|
||||
| 设置 | 默认值 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `core.log_level` | `info` | 日常日志等级。 |
|
||||
| `inbounds.rule_http_port` | `20172` | 规则 HTTP 代理入口。 |
|
||||
| `routing.mode` | `whitelist` | 国内/私有直连,其它代理。 |
|
||||
| `transparent.mode` | `close` | 默认不启用透明代理。 |
|
||||
| `transparent.type` | `redirect` | 推荐先用 redirect。 |
|
||||
| `dns.query_strategy` | `UseIPv4` | 默认优先 IPv4。 |
|
||||
| `dns.local_dns_listen` | `true` | redirect 透明代理下生成本地 DNS 入站。 |
|
||||
@@ -1,32 +0,0 @@
|
||||
# Xray 资源下载配置
|
||||
|
||||
对应 `download.toml` 和 `XrayAssetSettings`,在“下载”页显示。
|
||||
|
||||
| 设置 | UI | 默认值 | 可选值 | 作用 | 什么时候修改 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `directory` | 下载页 | `data/xray`;Docker 中通常为 `/config/xray` | 路径 | Xray 资源保存目录。 | Docker 部署保持 `/config/xray`;本机运行可用默认值。 |
|
||||
| `version` | 下载页 | `v26.5.9` | Xray release tag | 官方 release 版本。 | 需要固定或升级 Xray 版本时修改。 |
|
||||
| `archive_url` | 下载页 | `""` | URL | 自定义 Xray release zip 地址;为空时用官方地址。 | 官方下载慢或使用镜像时修改。 |
|
||||
| `geoip_url` | 下载页 | `""` | URL | 自定义 `geoip.dat` 下载地址。 | 需要替换 geoip 数据源时修改。 |
|
||||
| `geosite_url` | 下载页 | `""` | URL | 自定义 `geosite.dat` 下载地址。 | 需要替换 geosite 数据源时修改。 |
|
||||
| `proxy_url` | 下载页 | `""` | HTTP/HTTPS 代理 URL | 下载资源时使用的代理。 | 服务器直连 GitHub 慢或失败时修改。 |
|
||||
| `target` | 下载页 | `all` | `all` / `xray` / `geoip` / `geosite` | 本次下载目标。 | 只更新某个资源时修改。 |
|
||||
| `force` | 下载页 | `false` | `bool` | 已存在文件是否覆盖。 | 要强制重新下载时开启。 |
|
||||
|
||||
## 必需文件
|
||||
|
||||
| 文件 | 作用 |
|
||||
| --- | --- |
|
||||
| `xray` | Xray 可执行文件。 |
|
||||
| `geoip.dat` | IP 地理库,用于 `geoip:*` 规则。 |
|
||||
| `geosite.dat` | 域名分类库,用于 `geosite:*` 规则。 |
|
||||
|
||||
## 下载行为
|
||||
|
||||
| 条件 | 行为 |
|
||||
| --- | --- |
|
||||
| `target = all` | 确保三个必需文件都存在。 |
|
||||
| `force = false` 且文件存在 | 跳过已有文件。 |
|
||||
| `force = true` | 覆盖目标文件。 |
|
||||
| `geoip_url` / `geosite_url` 非空 | 对应 dat 文件使用自定义 URL,优先于 release zip 内置版本。 |
|
||||
| Docker 部署 | 资源仍由 Web 下载页处理,不在 Dockerfile 中下载。 |
|
||||
@@ -1,22 +0,0 @@
|
||||
# 核心配置
|
||||
|
||||
对应 `settings.toml` 的 `[core]`。
|
||||
|
||||
| 设置 | UI | 默认值 | 可选值 | 作用 | 什么时候修改 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `log_level` | 显示 | `info` | `debug` / `info` / `warning` / `error` / `none` | 写入 Xray `log.loglevel`。`debug` 便于排查,`none` 基本关闭日志。 | 排查启动失败、路由/DNS 行为异常时改 `debug`;日常用 `info`。 |
|
||||
| `mux_enabled` | 间接显示 | `false` | `bool` | 控制主 `proxy` outbound 是否生成 `mux`。UI 通过 `mux_concurrency = 0` 表示关闭。 | 节点支持且希望复用连接时开启;兼容性异常时关闭。 |
|
||||
| `mux_concurrency` | 显示 | `8` | UI:`0/1/2/4/8/16/32/64`;模型:`1-1024` | `mux.concurrency`。UI 选择 `0` 时保存为 `mux_enabled = false` 且并发恢复为 `8`。 | 高并发小连接场景可尝试 `8/16`;不确定用 `0`。 |
|
||||
| `tcp_fast_open` | 隐藏 | `default` | `default` / `yes` / `no` | 非 `default` 时写入 outbound `streamSettings.sockopt.tcpFastOpen`。 | 只有明确知道系统和网络支持 TFO 时修改。 |
|
||||
| `ss_backend` | 隐藏 | `""` | 字符串 | 预留字段,当前不影响 Xray JSON。 | 当前不用改。 |
|
||||
| `trojan_backend` | 隐藏 | `""` | 字符串 | 预留字段,当前不影响 Xray JSON。 | 当前不用改。 |
|
||||
|
||||
## 生成影响
|
||||
|
||||
| 条件 | 生成结果 |
|
||||
| --- | --- |
|
||||
| `log_level = debug` | `log.loglevel = debug`,`access = ""`,`error = ""`。 |
|
||||
| `log_level = warning` | `log.loglevel = warning`,`access = "none"`。 |
|
||||
| `log_level = none` | `log.loglevel = none`,`access = "none"`,`error = "none"`。 |
|
||||
| `mux_enabled = true` | 主代理 outbound 增加 `mux.enabled = true` 和 `mux.concurrency`。 |
|
||||
| `tcp_fast_open != default` | `proxy` / `direct` outbound 增加 `sockopt.tcpFastOpen`。 |
|
||||
@@ -1,46 +0,0 @@
|
||||
# DNS 配置
|
||||
|
||||
对应 `settings.toml` 的 `[dns]` 和 `[[dns.rules]]`。
|
||||
|
||||
| 设置 | UI | 默认值 | 可选值 | 作用 | 什么时候修改 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `query_strategy` | 显示 | `UseIPv4` | 空 / `UseIP` / `UseIPv4` / `UseIPv6` | 写入 `dns.queryStrategy`。空值表示不写该字段。 | IPv6 可用时可改 `UseIP` / `UseIPv6`;IPv6 不稳定保持 `UseIPv4`。 |
|
||||
| `disable_fallback` | 显示 | `false` | `bool` | 为 `true` 时写入 `dns.disableFallback`。 | 想严格按 DNS 规则解析时开启;解析容错下降。 |
|
||||
| `local_dns_listen` | 显示 | `true` | `bool` | 控制是否生成本地 DNS 入站。 | redirect 透明代理下需要接管宿主机 DNS 时开启。 |
|
||||
| `hosts` | 隐藏 | `{courier.push.apple.com = ["1-courier.push.apple.com"]}` | 字典 | 写入 `dns.hosts`。 | 需要固定域名解析时手改。 |
|
||||
| `rules` | 显示 | 见下表 | `server|domains|outbound` | 生成 `dns.servers`,并为 DNS 服务器自身生成 routing。 | 调整国内/国外 DNS、DoH、DNS 出口时修改。 |
|
||||
| `antipollution` | 显示 | `closed` | `closed` / `none` / `dnsforward` / `doh` / `advanced` | 预留字段,当前不直接影响 Xray JSON。 | 当前一般不改。 |
|
||||
| `special_mode` | 显示 | `none` | `none` / `supervisor` / `fakedns` | 预留字段,当前不直接影响 Xray JSON。 | 当前一般不改。 |
|
||||
|
||||
## 默认 DNS 规则
|
||||
|
||||
| server | domains | outbound | 作用 |
|
||||
| --- | --- | --- | --- |
|
||||
| `localhost` | `geosite:private` | `direct` | 私有域名走本地 DNS。 |
|
||||
| `223.5.5.5` | `geosite:cn` | `direct` | 中国域名走国内 DNS。 |
|
||||
| `8.8.8.8` | 空 | `proxy` | 兜底 DNS 走代理。 |
|
||||
|
||||
## DNS 规则字段
|
||||
|
||||
| 字段 | 默认值 | 可选值 | 作用 |
|
||||
| --- | --- | --- | --- |
|
||||
| `server` | 无 | `localhost`、IP、`host:port`、DoH URL | DNS 服务器地址。 |
|
||||
| `domains` | `""` | 换行/规则字符串 | 非空时只匹配这些域名规则;空表示默认 DNS。 |
|
||||
| `outbound` | `direct` | `direct` / `proxy` / `block` / 自定义 tag | 连接该 DNS 服务器自身时使用的出口。 |
|
||||
|
||||
## 本地 DNS 入站
|
||||
|
||||
| 条件 | 生成结果 |
|
||||
| --- | --- |
|
||||
| `local_dns_listen = true` 且 `transparent.mode != close` 且 `transparent.type = redirect` | 生成 `dns-in`。 |
|
||||
| 同时 `inbounds.port_sharing = true` | 额外生成 `dns-in-local` 和 `0.0.0.0:53`。 |
|
||||
| 其它情况 | 不生成本地 DNS 入站。 |
|
||||
|
||||
## 生成影响
|
||||
|
||||
| 行为 | 说明 |
|
||||
| --- | --- |
|
||||
| 有 `domains` 的 DNS rule | 生成 `{address, domains}` server。 |
|
||||
| 无 `domains` 的 DNS rule | 插入到 `dns.servers` 前部,作为默认 DNS。 |
|
||||
| DNS 服务器不是 `localhost` | 生成一条 routing rule,按 `outbound` 连接 DNS 服务器。 |
|
||||
| 节点服务器是域名 | 额外加入 DNS lookup domains,避免代理节点域名解析走错。 |
|
||||
@@ -1,36 +0,0 @@
|
||||
# 入站配置
|
||||
|
||||
对应 `settings.toml` 的 `[inbounds]` 和 `[inbounds.api]`。
|
||||
|
||||
| 设置 | UI | 默认值 | 可选值 | 作用 | 什么时候修改 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `listen` | 显示 | `127.0.0.1` | `127.0.0.1` / `0.0.0.0` | 普通入站、规则入站、VMess 入站监听地址。 | 只给本机用选 `127.0.0.1`;局域网设备要访问代理端口选 `0.0.0.0`。 |
|
||||
| `port_sharing` | 隐藏 | `false` | `bool` | 为 `true` 时监听地址强制变成 `0.0.0.0`。 | 当前 UI 用 `listen` 控制,通常不改。 |
|
||||
| `socks_port` | 隐藏 | `20170` | `0-65535` | 普通 SOCKS 入站,流量最终兜底走 `proxy`。UI 保存时写 `0`。 | 需要无规则 SOCKS 入口时手改。 |
|
||||
| `http_port` | 隐藏 | `20171` | `0-65535` | 普通 HTTP 入站,流量最终兜底走 `proxy`。UI 保存时写 `0`。 | 需要无规则 HTTP 入口时手改。 |
|
||||
| `rule_socks_port` | 显示 | `0` | `0-65535` | 规则 SOCKS 入站,流量按 `[routing]` 规则分流。UI 显示值会在为 `0` 时回退展示 `socks_port`。 | 应用要用 SOCKS 并希望按规则分流时设置。 |
|
||||
| `rule_http_port` | 显示 | `20172` | `0-65535` | 规则 HTTP 入站,流量按 `[routing]` 规则分流。 | 浏览器/系统显式 HTTP 代理时使用。 |
|
||||
| `vmess_port` | 隐藏 | `0` | `0-65535` | 额外 VMess 入站。`0` 不生成。 | 当前很少需要。 |
|
||||
| `inbound_sniffing` | 显示 | `http,tls,quic` | `disable` / `http,tls` / `http,tls,quic` | 写入每个支持入站的 `sniffing.destOverride`。 | 域名路由不准时保持开启;兼容性异常时降级或关闭。 |
|
||||
| `route_only` | 显示 | `false` | `bool` | 写入 `sniffing.routeOnly`。 | 只希望嗅探域名用于路由、不改连接目标时开启。 |
|
||||
| `domains_excluded` | 隐藏 | `""` | 换行分隔域名 | 写入 `sniffing.domainsExcluded`。 | 特定域名被 sniffing 影响时手改。 |
|
||||
| `api.port` | 隐藏 | `0` | `0-65535` | Xray API 入站端口,`0` 不生成。 | 需要 Xray API 服务时手改。 |
|
||||
| `api.services` | 隐藏 | `["LoggerService"]` | 字符串数组 | 写入 Xray `api.services`,生成时确保包含 `LoggerService`。 | 需要额外 API service 时手改。 |
|
||||
| `custom` | 隐藏 | `[]` | `[[inbounds.custom]]` | 额外 socks/http 入站。 | 需要多个固定端口或不同 tag 时手改。 |
|
||||
|
||||
## 自定义入站
|
||||
|
||||
| 设置 | 默认值 | 可选值 | 作用 |
|
||||
| --- | --- | --- | --- |
|
||||
| `tag` | 无 | 非空字符串 | 入站 tag。 |
|
||||
| `protocol` | 无 | `socks` / `http` | 入站协议。 |
|
||||
| `port` | 无 | `1-65535` | 监听端口。 |
|
||||
|
||||
## 生成影响
|
||||
|
||||
| 入站 tag | 来源 | 路由行为 |
|
||||
| --- | --- | --- |
|
||||
| `socks` / `http` | `socks_port` / `http_port` | 不进入 `[routing]` 模式规则,最终兜底走 `proxy`。 |
|
||||
| `rule-socks` / `rule-http` | `rule_socks_port` / `rule_http_port` | 进入 `[routing]` 模式规则。 |
|
||||
| `vmess` | `vmess_port` | 额外 VMess 入站。 |
|
||||
| `api-in` | `api.port` | 路由到 `api-out`。 |
|
||||
@@ -1,22 +0,0 @@
|
||||
# 出站组和自动更新配置
|
||||
|
||||
当前 pyxray 是“单选中节点”模型:选中的节点会生成 tag 为 `proxy` 的主 outbound,另外固定生成 `direct`、`block`、`dns-out`。
|
||||
|
||||
## `[[outbounds]]`
|
||||
|
||||
| 设置 | UI | 默认值 | 可选值 | 作用 | 什么时候修改 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `tag` | 隐藏 | `proxy` | 字符串 | 出站组 tag。当前生成逻辑固定使用 `proxy`。 | 当前不用改。 |
|
||||
| `probe_url` | 隐藏 | `https://www.gstatic.com/generate_204` | URL | 预留给 observatory/balancer。当前不写入 Xray JSON。 | 当前不用改。 |
|
||||
| `probe_interval` | 隐藏 | `60s` | duration 字符串 | 预留给 observatory/balancer。当前不写入 Xray JSON。 | 当前不用改。 |
|
||||
| `type` | 隐藏 | `leastping` | 字符串 | 预留策略类型。当前不写入 Xray JSON。 | 当前不用改。 |
|
||||
|
||||
## `[auto_update]`
|
||||
|
||||
| 设置 | UI | 默认值 | 可选值 | 作用 | 什么时候修改 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `gfwlist_auto_update_mode` | 隐藏 | `none` | `none` / `auto_update` / `auto_update_at_intervals` | GFWList 更新策略预留。当前不执行自动更新。 | 当前不用改。 |
|
||||
| `gfwlist_auto_update_interval_hour` | 隐藏 | `0` | 整数 | GFWList 定时更新间隔预留。 | 当前不用改。 |
|
||||
| `subscription_auto_update_mode` | 隐藏 | `none` | `none` / `auto_update` / `auto_update_at_intervals` | 订阅更新策略预留。当前无订阅功能。 | 当前不用改。 |
|
||||
| `subscription_auto_update_interval_hour` | 隐藏 | `0` | 整数 | 订阅定时更新间隔预留。 | 当前不用改。 |
|
||||
| `proxy_mode_when_subscribe` | 隐藏 | `direct` | `direct` / `proxy` / `pac` | 订阅更新时连接模式预留。当前无订阅功能。 | 当前不用改。 |
|
||||
@@ -1,43 +0,0 @@
|
||||
# 路由配置
|
||||
|
||||
对应 `settings.toml` 的 `[routing]`。
|
||||
|
||||
自定义规则详细语法见 [路由自定义规则](../routing-custom-rules.md)。
|
||||
|
||||
| 设置 | UI | 默认值 | 可选值 | 作用 | 什么时候修改 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `mode` | 显示 | `whitelist` | UI:`whitelist` / `gfwlist` / `proxy` / `direct` / `block`;模型另支持 `custom` / `routingA` | 控制 rule 入站和部分透明代理流量的分流模式。 | 改变整体分流策略时修改。 |
|
||||
| `default_rule` | 隐藏 | `proxy` | `direct` / `proxy` / `block` | `custom` / `routingA` 等模式的兜底出口。UI 保存时固定为 `proxy`。 | 需要兜底直连或阻断时手改。 |
|
||||
| `routing_a` | 显示 | `""` | RoutingA 风格文本 | 解析 `domain(...) -> outbound` 和 `ip(...) -> outbound` 规则,优先于内置模式规则。 | 少量自定义前置规则时使用。 |
|
||||
| `custom_rules` | 隐藏 | `[]` | `[[routing.custom_rules]]` | `mode = custom` 时生成规则。 | 需要结构化 custom TOML 规则时手改。 |
|
||||
|
||||
## 路由模式
|
||||
|
||||
| 模式 | 行为 | 适用场景 |
|
||||
| --- | --- | --- |
|
||||
| `whitelist` | Apple push 直连;`geolocation-!cn`、Google、港澳 IP 代理;`geosite:cn`、私有/中国 IP 直连;兜底按 `default_rule`。 | 国内直连、国外代理。 |
|
||||
| `gfwlist` | `geolocation-!cn` 和 Telegram IP 代理;其它直连。 | 只代理规则命中的目标。 |
|
||||
| `proxy` | 全部走 `proxy`。 | 简单全局代理。 |
|
||||
| `direct` | 全部走 `direct`。 | 临时关闭代理但保留服务。 |
|
||||
| `block` | 全部走 `block`。 | 测试或阻断入口流量。 |
|
||||
| `custom` | 使用 `custom_rules`,最后走 `default_rule`。 | 结构化规则。 |
|
||||
| `routingA` | 使用 `routing_a`,最后走 `default_rule`。 | 兼容 RoutingA 风格配置。 |
|
||||
|
||||
## `routing_a` 语法
|
||||
|
||||
| 语法 | 示例 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `domain(...) -> outbound` | `domain(geosite:google)->proxy` | 写入 Xray rule 的 `domain`。 |
|
||||
| `ip(...) -> outbound` | `ip(geoip:cn)->direct` | 写入 Xray rule 的 `ip`。 |
|
||||
| 注释 | `# comment` | 空行和 `#` 开头行忽略。 |
|
||||
|
||||
`outbound` 可用 `proxy`、`direct`、`block`,也可以是自定义 outbound tag。
|
||||
|
||||
## `custom_rules`
|
||||
|
||||
| 设置 | UI | 默认值 | 可选值 | 作用 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `filename` | 隐藏 | `""` | 字符串 | 非空时生成 `ext:<filename>:<tag>`。 |
|
||||
| `tags` | 隐藏 | `[]` | 字符串数组 | geosite/geoip/tag 列表。 |
|
||||
| `match_type` | 隐藏 | `domain` | `domain` / `ip` | 决定写入 Xray rule 的 `domain` 还是 `ip`。 |
|
||||
| `rule_type` | 隐藏 | `proxy` | `direct` / `proxy` / `block` | 命中后的出口。 |
|
||||
@@ -1,75 +0,0 @@
|
||||
# 透明代理配置
|
||||
|
||||
对应 `settings.toml` 的 `[transparent]`。
|
||||
|
||||
透明代理由两部分组成:
|
||||
|
||||
| 部分 | 作用 |
|
||||
| --- | --- |
|
||||
| Xray inbound | 接收被系统规则转发来的流量。 |
|
||||
| 系统规则 | `iptables` / `nft` / `ip rule` / `resolv.conf`,把宿主机流量导到 Xray。 |
|
||||
|
||||
## 字段
|
||||
|
||||
| 设置 | UI | 默认值 | 可选值 | 作用 | 什么时候修改 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `mode` | 显示 | `close` | `close` / `proxy` / `whitelist` / `gfwlist` / `pac` | 是否启用 transparent inbound,以及透明代理流量使用的路由模式。 | 需要宿主机透明代理时改为非 `close`。 |
|
||||
| `type` | 显示 | `redirect` | `redirect` / `tproxy` / `system_proxy` / `tun` | 透明代理接入方式。 | 先用 `redirect`;需要 UDP 再评估 `tproxy`。 |
|
||||
| `port` | 显示 | `52345` | `0-65535` | redirect/tproxy/system_proxy/tun 主端口。 | 端口冲突时修改。 |
|
||||
| `socks_port` | 显示 | `52306` | `0-65535` | `system_proxy` 额外 SOCKS 端口。 | 只在 system proxy 场景修改。 |
|
||||
| `ipforward` | 显示 | `false` | `bool` | 生成脚本写 `/proc/sys/net/ipv4/ip_forward` 和 IPv6 forwarding。 | 宿主机要作为网关转发其它设备/容器流量时开启。 |
|
||||
| `docker_transparent` | 显示 | `true` | `bool` | redirect/tproxy 规则只关注指定 Docker CIDR,并不过滤 docker/veth/br-* 接口。 | 要透明代理 Docker 容器流量时保持开启。 |
|
||||
| `docker_transparent_cidrs` | 显示 | `172.16.0.0/12` | 分号分隔 IPv4 CIDR | Docker 容器源地址网段。 | Docker 网段不是默认范围时修改。 |
|
||||
| `tproxy_excluded_interfaces` | 显示 | `docker*,veth*,wg*,ppp*,br-*` | 逗号分隔接口模式 | 系统规则排除入口接口。 | tproxy/redirect 误拦截特定接口时修改。 |
|
||||
| `tproxy_white_country_codes` | 隐藏 | `[]` | 国家/地区代码数组 | 从 `geoip.dat` 解析白名单 CIDR,tproxy 下 RETURN。 | tproxy 下需要国家/地区直连白名单时手改。 |
|
||||
| `tproxy_white_custom_ips` | 隐藏 | `[]` | CIDR 数组 | tproxy 自定义 RETURN CIDR。 | tproxy 下需要额外直连网段时手改。 |
|
||||
| `tun_bypass_interfaces` | 隐藏 | `""` | 字符串 | 写入 `tinytun.yaml` 的绕过接口。 | 使用 TinyTun 且要绕过接口时手改。 |
|
||||
| `tun_auto_route` | 显示 | `true` | `bool` | 写入 `tinytun.yaml` 的 `auto_route`。 | TUN 路由由外部管理时关闭。 |
|
||||
| `tun_route_shell_type` | 隐藏 | `""` | 字符串 | 预留。 | 当前不用改。 |
|
||||
| `tun_route_shell_path` | 隐藏 | `""` | 字符串 | 预留。 | 当前不用改。 |
|
||||
| `tun_setup_script` | 隐藏 | `""` | shell 文本 | `type = tun` 时生成 setup 脚本内容。 | 需要自定义 TUN 初始化时手改。 |
|
||||
| `tun_teardown_script` | 隐藏 | `""` | shell 文本 | `type = tun` 时生成 cleanup 脚本内容。 | 需要自定义 TUN 清理时手改。 |
|
||||
| `tun_process_backend` | 隐藏 | `""` | 字符串 | 写入 TinyTun 进程匹配后端。 | 需要进程分流时手改。 |
|
||||
| `tun_exclude_processes` | 隐藏 | `""` | 换行/逗号分隔字符串 | 写入 TinyTun 排除进程。 | 避免 Xray/pyxray 自身进入 TUN 回环时修改。 |
|
||||
|
||||
## 模式行为
|
||||
|
||||
| `mode` | 透明代理路由行为 |
|
||||
| --- | --- |
|
||||
| `close` | 不生成 transparent inbound,不执行透明代理 setup。 |
|
||||
| `proxy` | transparent 流量全部走 `proxy`。 |
|
||||
| `whitelist` | transparent 流量按 whitelist 规则。 |
|
||||
| `gfwlist` | transparent 流量按 gfwlist 规则。 |
|
||||
| `pac` | transparent 流量复用 `[routing].mode`。 |
|
||||
|
||||
## 类型行为
|
||||
|
||||
| `type` | Xray inbound | 系统规则 | 适用 |
|
||||
| --- | --- | --- | --- |
|
||||
| `redirect` | `dokodemo-door` + `followRedirect` + `tproxy=redirect` | nat 表 REDIRECT / nft redirect | 推荐默认;主要处理 TCP。 |
|
||||
| `tproxy` | `dokodemo-door` + `followRedirect` + `tproxy=tproxy` | mangle + fwmark + table 100 / nft tproxy | 需要 UDP/TProxy 时。 |
|
||||
| `system_proxy` | HTTP + SOCKS | 不改内核规则,只生成占位脚本 | 应用显式配置代理。 |
|
||||
| `tun` | SOCKS 入站 + `tinytun.yaml` | 使用 `tun_setup_script` / `tun_teardown_script` | 外部 TinyTun 方案。 |
|
||||
|
||||
## Docker 部署注意
|
||||
|
||||
| 配置 | 说明 |
|
||||
| --- | --- |
|
||||
| `network_mode: host` | 规则作用于宿主机网络命名空间。 |
|
||||
| `privileged: true` | 允许改 iptables/nft/ip rule/procfs。 |
|
||||
| `/etc/resolv.conf:/etc/resolv.conf` | redirect DNS 劫持时修改宿主机 DNS。 |
|
||||
| `docker_transparent = true` | 生成 Docker CIDR 相关 PREROUTING 规则。 |
|
||||
|
||||
## 生成文件
|
||||
|
||||
| 文件 | 用途 |
|
||||
| --- | --- |
|
||||
| `ip-forward-apply.sh` | 写 IP forwarding。 |
|
||||
| `resolv-hijack-setup.sh` | redirect DNS 劫持。 |
|
||||
| `resolv-hijack-cleanup.sh` | 恢复 DNS。 |
|
||||
| `transparent-iptables-setup.sh` | 安装 iptables 规则。 |
|
||||
| `transparent-iptables-cleanup.sh` | 清理 iptables 规则。 |
|
||||
| `transparent-nft-setup.sh` | 加载 nftables 规则。 |
|
||||
| `transparent-nft-cleanup.sh` | 清理 nftables 规则。 |
|
||||
| `v2raya.nft` | nftables 表内容。 |
|
||||
| `tinytun.yaml` | TinyTun 配置。 |
|
||||
+51
-210
@@ -1,220 +1,61 @@
|
||||
# 项目结构和调用时序
|
||||
# 基础架构方案
|
||||
|
||||
## 目录结构
|
||||
## 前端
|
||||
|
||||
| 路径 | 职责 |
|
||||
| --- | --- |
|
||||
| `pyxray/cli.py` | 命令行入口。默认启动 Web。 |
|
||||
| `pyxray/web/server.py` | 创建 Flask app,注册各 API 和生命周期清理。 |
|
||||
| `pyxray/web/dashboard.py` | 渲染首页。 |
|
||||
| `pyxray/web/jobs.py` | 内存任务表,当前用于下载任务轮询。 |
|
||||
| `pyxray/web/nodes.py` | 节点 API。 |
|
||||
| `pyxray/web/xray_assets.py` | Xray 资源下载 API。 |
|
||||
| `pyxray/web/xray_config.py` | 配置保存和生成 API。 |
|
||||
| `pyxray/web/xray_service.py` | Xray 启停和日志 API。 |
|
||||
| `pyxray/web/templates/` | Jinja 页面和配置表单。 |
|
||||
| `pyxray/web/static/` | 前端 JS/CSS。 |
|
||||
| `pyxray/libs/nodes/` | 节点链接解析、标准化、TOML 存储。 |
|
||||
| `pyxray/libs/xray_assets.py` | 下载和检查 `xray`、`geoip.dat`、`geosite.dat`。 |
|
||||
| `pyxray/libs/xray_asset_settings.py` | `download.toml` 读写。 |
|
||||
| `pyxray/libs/xray_config/` | Xray JSON、透明代理脚本、TinyTun 配置和设置存储。 |
|
||||
| `pyxray/libs/xray_runtime.py` | Xray 子进程管理、端口检查、日志转发。 |
|
||||
| `pyxray/libs/xray_transparent_runtime.py` | 透明代理脚本执行、回滚、本地 CIDR watcher。 |
|
||||
| `tests/` | 单元测试和 Web API 测试。 |
|
||||
| `docs/` | 使用和配置文档。 |
|
||||
| `scripts/build.sh` | Docker 镜像构建脚本。 |
|
||||
| `compose.yaml` | Docker 透明代理部署。 |
|
||||
- 使用 Bun 管理依赖、开发和构建。
|
||||
- 使用 React 编写 UI。
|
||||
- 使用 shadcn/Radix 作为组件基础。
|
||||
- 使用 TailwindCSS 编写样式。
|
||||
- 前端只通过协议层定义的 HTTP API 与后端通信。
|
||||
- 前端不直接读取 backend 文件,也不依赖 Python 内部实现。
|
||||
|
||||
## 数据文件
|
||||
## 协议
|
||||
|
||||
| 文件 | 创建方 | 读写时机 |
|
||||
| --- | --- | --- |
|
||||
| `nodes.toml` | `NodeStore` | 导入、选择、删除节点。 |
|
||||
| `settings.toml` | `XrayConfigSettingsStore` | 保存配置页设置、生成配置、启动 Xray。 |
|
||||
| `download.toml` | `XrayAssetSettingsStore` | 保存下载页设置、启动下载任务。 |
|
||||
| `config.json` | `generate_current_xray_config` | 生成配置、启动 Xray 前。 |
|
||||
| `xray.log` | `XrayServiceManager` / `TransparentRuntime` | 启停 Xray、转发 Xray 输出、透明代理脚本日志。 |
|
||||
| `transparent/*` | `write_transparent_rule_files` | 生成配置、启动 Xray 前。 |
|
||||
- `dev/protocol/` 是前后端之间的稳定边界。
|
||||
- HTTP API 使用 OpenAPI 3.1 定义。
|
||||
- 共享数据结构使用 JSON Schema 表达。
|
||||
- 前端 TypeScript 类型和 API client 应从协议生成。
|
||||
- 后端必须实现协议,而不是让协议跟随后端内部结构变化。
|
||||
|
||||
## Web App 装配
|
||||
## 后端
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
CLI[pyxray cli] --> RunWeb[run_web]
|
||||
RunWeb --> CreateApp[create_app]
|
||||
CreateApp --> Jobs[init_job_store]
|
||||
CreateApp --> Assets[register_xray_assets]
|
||||
CreateApp --> Nodes[register_nodes]
|
||||
CreateApp --> Config[register_xray_config]
|
||||
CreateApp --> Service[register_xray_service]
|
||||
CreateApp --> Lifecycle[_bind_xray_lifecycle]
|
||||
CreateApp --> Dashboard[register_dashboard]
|
||||
- 使用 Python 实现当前后端。
|
||||
- 使用 FastAPI 提供 HTTP API。
|
||||
- 使用 Pydantic v2 做数据校验和 DTO。
|
||||
- 使用 Typer 提供 CLI。
|
||||
- 使用 uv 管理 Python 环境和依赖。
|
||||
- 使用 pytest 做测试。
|
||||
- 使用 ruff 做 lint 和 format。
|
||||
- 本地状态持久化优先使用 SQLite。
|
||||
|
||||
## 后端分层
|
||||
|
||||
后端目录按职责划分:
|
||||
|
||||
```text
|
||||
dev/backend/
|
||||
src/pyxray_backend/
|
||||
api/
|
||||
cli/
|
||||
core/
|
||||
runtime/
|
||||
storage/
|
||||
subscriptions/
|
||||
xray/
|
||||
tests/
|
||||
```
|
||||
|
||||
## 首页渲染
|
||||
- `api/` 只负责 HTTP 适配。
|
||||
- `cli/` 只负责命令行适配。
|
||||
- `core/` 放核心用例逻辑。
|
||||
- `runtime/` 放运行态状态、日志和生命周期管理。
|
||||
- `storage/` 放本地持久化。
|
||||
- `subscriptions/` 放节点订阅解析。
|
||||
- `xray/` 放 Xray 下载、配置生成和进程管理。
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant B as Browser
|
||||
participant D as dashboard.index
|
||||
participant A as AssetSettingsStore
|
||||
participant N as NodeManager
|
||||
participant C as XrayConfigSettingsStore
|
||||
participant S as XrayServiceManager
|
||||
## 边界原则
|
||||
|
||||
B->>D: GET /
|
||||
D->>A: load download.toml
|
||||
D->>N: list_nodes + selected_id
|
||||
D->>C: load settings.toml
|
||||
D->>S: status
|
||||
D-->>B: render index.html
|
||||
```
|
||||
|
||||
## 资源下载
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant B as Browser
|
||||
participant API as /api/xray/assets/ensure
|
||||
participant Store as XrayAssetSettingsStore
|
||||
participant Jobs as JobStore
|
||||
participant Worker as _run_asset_job
|
||||
participant Assets as ensure_xray_assets
|
||||
|
||||
B->>API: POST form
|
||||
API->>Store: save download.toml
|
||||
API->>Jobs: start worker
|
||||
API-->>B: job_id
|
||||
Worker->>Assets: check/download/extract
|
||||
Worker->>Jobs: update steps/status
|
||||
B->>Jobs: GET job status
|
||||
Jobs-->>B: progress/result
|
||||
```
|
||||
|
||||
## 节点导入和选择
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant B as Browser
|
||||
participant API as nodes API
|
||||
participant M as NodeManager
|
||||
participant P as parse_node_link
|
||||
participant S as NodeStore
|
||||
|
||||
B->>API: POST /api/nodes/import
|
||||
API->>M: import_links
|
||||
M->>P: parse + normalize
|
||||
M->>S: save nodes.toml
|
||||
API-->>B: import results
|
||||
B->>API: POST /api/nodes/select
|
||||
API->>M: select_node
|
||||
M->>S: save selected_id
|
||||
API-->>B: selected node
|
||||
```
|
||||
|
||||
## 配置生成
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant B as Browser
|
||||
participant API as /api/xray/config/generate
|
||||
participant N as NodeManager
|
||||
participant S as SettingsStore
|
||||
participant G as generate_xray_config
|
||||
participant T as write_transparent_rule_files
|
||||
participant U as write_tinytun_config_file
|
||||
participant FS as data directory
|
||||
|
||||
B->>API: POST generate
|
||||
API->>N: get_selected_node
|
||||
API->>S: load settings.toml
|
||||
API->>G: node + settings
|
||||
G-->>API: config dict
|
||||
API->>FS: write config.json
|
||||
API->>T: write transparent scripts
|
||||
API->>U: write tinytun.yaml if needed
|
||||
API-->>B: config + paths
|
||||
```
|
||||
|
||||
## 启动 Xray 和透明代理
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant B as Browser
|
||||
participant API as /api/xray/service/start
|
||||
participant G as generate_current_xray_config
|
||||
participant X as XrayServiceManager
|
||||
participant R as TransparentRuntime
|
||||
participant OS as Host network
|
||||
|
||||
B->>API: POST start
|
||||
API->>G: regenerate config and scripts
|
||||
API->>X: status
|
||||
API->>X: start xray
|
||||
X->>X: check inbound ports
|
||||
X->>OS: Popen xray run -config config.json
|
||||
X->>X: forward stdout/stderr to xray.log
|
||||
API->>R: setup settings
|
||||
R->>R: cleanup old rules best-effort
|
||||
R->>OS: run ip-forward script
|
||||
R->>OS: run iptables setup, fallback nft
|
||||
R->>OS: run resolv setup
|
||||
R->>R: start local CIDR watcher
|
||||
API-->>B: running status
|
||||
```
|
||||
|
||||
## 停止和清理
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant B as Browser
|
||||
participant API as /api/xray/service/stop
|
||||
participant X as XrayServiceManager
|
||||
participant R as TransparentRuntime
|
||||
participant OS as Host network
|
||||
|
||||
B->>API: POST stop
|
||||
API->>X: stop
|
||||
X->>R: before_stop cleanup
|
||||
R->>R: stop local CIDR watcher
|
||||
R->>OS: run resolv cleanup
|
||||
R->>OS: run transparent backend cleanup
|
||||
X->>OS: terminate xray process
|
||||
API-->>B: stopped status
|
||||
```
|
||||
|
||||
## Docker 透明代理部署
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Compose["docker compose"] --> Container["pyxray container"]
|
||||
Container --> HostNet["host network namespace"]
|
||||
Container --> ConfigVol["data volume mounted to config"]
|
||||
Container --> Resolv["resolv.conf bind mount"]
|
||||
Container --> Modules["lib modules read-only mount"]
|
||||
HostNet --> XrayPorts["Xray listens on host ports"]
|
||||
HostNet --> Rules["iptables nft ip rule affect host"]
|
||||
Resolv --> DNS["host DNS hijack when enabled"]
|
||||
```
|
||||
|
||||
关键点:
|
||||
|
||||
| Compose 配置 | 原因 |
|
||||
| --- | --- |
|
||||
| `network_mode: host` | Xray 端口和透明代理规则直接作用于宿主机。 |
|
||||
| `privileged: true` | 允许改防火墙、策略路由、procfs。 |
|
||||
| `./data:/config` | 容器重建后状态不丢。 |
|
||||
| `/etc/resolv.conf:/etc/resolv.conf` | redirect DNS 劫持修改宿主机 DNS。 |
|
||||
| `/lib/modules:/lib/modules:ro` | 读取宿主机内核模块信息。 |
|
||||
|
||||
## 修改建议
|
||||
|
||||
| 目标 | 优先修改位置 |
|
||||
| --- | --- |
|
||||
| 增加节点协议 | `pyxray/libs/nodes/parsers/`、`pyxray/libs/xray_config/outbound.py`。 |
|
||||
| 增加配置字段 | `settings.py`、`store.py`、配置模板、`generator.py`。 |
|
||||
| 改 Web API | `pyxray/web/*.py`。 |
|
||||
| 改配置生成 | `pyxray/libs/xray_config/generator.py`。 |
|
||||
| 改透明代理规则 | `transparent_rules.py`。 |
|
||||
| 改规则执行/回滚 | `xray_transparent_runtime.py`。 |
|
||||
| 改 Docker 部署 | `Dockerfile`、`compose.yaml`、`scripts/build.sh`。 |
|
||||
- 稳定 API 契约放在 `dev/protocol/`。
|
||||
- backend 是协议的一种实现,未来可以替换为 Rust。
|
||||
- frontend 依赖协议,不依赖 backend。
|
||||
- 不在没有明确需求前添加服务接口。
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
# 路由自定义规则
|
||||
|
||||
本文说明 pyxray 配置页“路由 / 自定义规则”的实际语法,以及它最终生成到 Xray `routing.rules` 的方式。
|
||||
|
||||
## 适用入口
|
||||
|
||||
| 入口 | 字段 | UI | 适合场景 |
|
||||
| --- | --- | --- | --- |
|
||||
| RoutingA 文本 | `routing.routing_a` | 显示 | 少量域名/IP 前置规则。 |
|
||||
| 结构化规则 | `routing.custom_rules` | 隐藏 | 手写 `settings.toml`,按 geosite/geoip/ext 列表分流。 |
|
||||
|
||||
pyxray 当前不会让你直接手写完整 Xray `routing.rules` JSON;它只提供上述两种简化输入,然后生成 Xray 规则。
|
||||
|
||||
## 匹配顺序
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Request["连接进入 rule 入站或透明代理入站"] --> Custom["先应用 routing_a 前置规则"]
|
||||
Custom --> Mode["再应用 routing.mode 内置规则"]
|
||||
Mode --> Default["最后应用 default_rule 兜底"]
|
||||
Default --> Outbound["proxy direct block"]
|
||||
```
|
||||
|
||||
Xray 原生规则按 `routing.rules` 从上到下匹配,命中第一条后使用该规则的 `outboundTag` 或 `balancerTag`。同一条规则里多个字段同时存在时是 AND 关系;同一字段数组内通常是 OR 关系。
|
||||
|
||||
## RoutingA 文本语法
|
||||
|
||||
| 语法 | 示例 | 生成字段 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `domain(...) -> proxy` | `domain(geosite:google)->proxy` | `domain` | 命中域名后走 `proxy`。 |
|
||||
| `domain(...) -> direct` | `domain(domain:example.com)->direct` | `domain` | 命中域名或子域名后直连。 |
|
||||
| `domain(...) -> block` | `domain(full:ads.example.com)->block` | `domain` | 命中完整域名后阻断。 |
|
||||
| `ip(...) -> proxy` | `ip(geoip:telegram)->proxy` | `ip` | 命中 IP 列表后代理。 |
|
||||
| `ip(...) -> direct` | `ip(geoip:private, geoip:cn)->direct` | `ip` | 命中私有或中国 IP 后直连。 |
|
||||
| 注释 | `# comment` | 无 | 空行和 `#` 开头行会被忽略。 |
|
||||
|
||||
格式要求:
|
||||
|
||||
| 项 | 要求 |
|
||||
| --- | --- |
|
||||
| 匹配器 | 只能是 `domain(...)` 或 `ip(...)`。 |
|
||||
| 分隔符 | 必须使用 `->`。 |
|
||||
| 多个值 | 用英文逗号分隔。 |
|
||||
| 出口 | 通常使用 `proxy`、`direct`、`block`。 |
|
||||
| 生效范围 | 只作用于 rule 入站和透明代理入站,不影响普通 `socks` / `http` 入站。 |
|
||||
|
||||
示例:
|
||||
|
||||
```toml
|
||||
[routing]
|
||||
mode = "whitelist"
|
||||
default_rule = "proxy"
|
||||
routing_a = """
|
||||
# 公司内网直连
|
||||
domain(domain:corp.example.com)->direct
|
||||
ip(10.0.0.0/8, 192.168.0.0/16)->direct
|
||||
|
||||
# Google 代理
|
||||
domain(geosite:google)->proxy
|
||||
|
||||
# 精确阻断广告域名
|
||||
domain(full:ads.example.com)->block
|
||||
"""
|
||||
```
|
||||
|
||||
## domain 值
|
||||
|
||||
| 写法 | 示例 | 匹配语义 |
|
||||
| --- | --- | --- |
|
||||
| `domain:` | `domain:example.com` | 匹配 `example.com` 和子域名,例如 `www.example.com`。 |
|
||||
| `full:` | `full:example.com` | 只完整匹配 `example.com`。 |
|
||||
| `keyword:` | `keyword:google` | 目标域名包含关键字即匹配。 |
|
||||
| 无前缀字符串 | `google` | 等价于 `keyword:google`。 |
|
||||
| `regexp:` | `regexp:\\.example\\.com$` | 使用正则匹配目标域名。 |
|
||||
| `dotless:` | `dotless:printer` | 匹配不含点的内网短域名。 |
|
||||
| `geosite:` | `geosite:cn` | 使用 `geosite.dat` 里的标签。 |
|
||||
| `ext:` | `ext:geosite.dat:cn` | 从资源目录里的外部 geosite 格式文件读取标签。 |
|
||||
|
||||
注意:
|
||||
|
||||
| 项 | 说明 |
|
||||
| --- | --- |
|
||||
| 推荐默认 | 常规域名优先用 `domain:example.com`。 |
|
||||
| 精确匹配 | 只想匹配单个域名时用 `full:`。 |
|
||||
| 正则转义 | 写进 TOML 字符串时反斜杠要按 TOML 规则转义。 |
|
||||
| 不支持 | `plain:` 不是当前 Xray 官方 routing 文档列出的 domain 前缀,不要使用。 |
|
||||
|
||||
## ip 值
|
||||
|
||||
| 写法 | 示例 | 匹配语义 |
|
||||
| --- | --- | --- |
|
||||
| 单个 IP | `1.1.1.1` | 匹配目标 IP。 |
|
||||
| CIDR | `10.0.0.0/8` | 匹配网段。 |
|
||||
| IPv6 CIDR | `fc00::/7` | 匹配 IPv6 网段。 |
|
||||
| `geoip:` | `geoip:cn` | 使用 `geoip.dat` 里的国家或分类标签。 |
|
||||
| `geoip:private` | `geoip:private` | 匹配私有地址。 |
|
||||
| `ext:` | `ext:geoip.dat:cn` | 从资源目录里的外部 geoip 格式文件读取标签。 |
|
||||
| `!` 反选 | `!geoip:cn` | 匹配不在该 IP 列表内的目标。 |
|
||||
|
||||
示例:
|
||||
|
||||
```toml
|
||||
[routing]
|
||||
mode = "routingA"
|
||||
default_rule = "proxy"
|
||||
routing_a = """
|
||||
ip(geoip:private, geoip:cn)->direct
|
||||
ip(geoip:telegram)->proxy
|
||||
ip(!geoip:cn)->proxy
|
||||
"""
|
||||
```
|
||||
|
||||
## custom_rules 结构化规则
|
||||
|
||||
`custom_rules` 只有在 `routing.mode = "custom"` 时作为主规则集使用。UI 暂不显示,需要手写 `settings.toml`。
|
||||
|
||||
| 字段 | 默认值 | 可选值 | 作用 |
|
||||
| --- | --- | --- | --- |
|
||||
| `filename` | `""` | 文件名 | 非空时把每个 tag 生成 `ext:<filename>:<tag>`。 |
|
||||
| `tags` | `[]` | 字符串数组 | 要匹配的 geosite/geoip/ext 标签。 |
|
||||
| `match_type` | `domain` | `domain` / `ip` | 决定生成 Xray rule 的 `domain` 还是 `ip`。 |
|
||||
| `rule_type` | `proxy` | `proxy` / `direct` / `block` | 命中后的出口。 |
|
||||
|
||||
示例:
|
||||
|
||||
```toml
|
||||
[routing]
|
||||
mode = "custom"
|
||||
default_rule = "proxy"
|
||||
|
||||
[[routing.custom_rules]]
|
||||
match_type = "domain"
|
||||
rule_type = "direct"
|
||||
tags = ["geosite:private", "geosite:cn"]
|
||||
|
||||
[[routing.custom_rules]]
|
||||
match_type = "ip"
|
||||
rule_type = "direct"
|
||||
tags = ["geoip:private", "geoip:cn"]
|
||||
|
||||
[[routing.custom_rules]]
|
||||
match_type = "domain"
|
||||
rule_type = "proxy"
|
||||
tags = ["geosite:geolocation-!cn"]
|
||||
```
|
||||
|
||||
使用外部文件:
|
||||
|
||||
```toml
|
||||
[[routing.custom_rules]]
|
||||
filename = "geosite.dat"
|
||||
match_type = "domain"
|
||||
rule_type = "proxy"
|
||||
tags = ["google"]
|
||||
```
|
||||
|
||||
上面会生成:
|
||||
|
||||
```json
|
||||
{
|
||||
"domain": ["ext:geosite.dat:google"],
|
||||
"outboundTag": "proxy"
|
||||
}
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
| 问题 | 原因 | 处理 |
|
||||
| --- | --- | --- |
|
||||
| 规则没生效 | 入口不是 rule 入站或透明代理入站。 | 使用 `rule_http_port` / `rule_socks_port`,或开启透明代理。 |
|
||||
| 域名规则没命中 | 流量只有 IP,没有域名。 | 开启 sniffing,或改用 `ip(...)` 规则。 |
|
||||
| IP 规则导致 DNS 查询 | 当前 pyxray 生成 `domainStrategy = "IPOnDemand"`。 | 避免过度使用 IP 规则,或接受 Xray 为路由进行 DNS 解析。 |
|
||||
| `routing_a` 里的 `default:` 无效 | pyxray 解析器只识别 `domain(...)` 和 `ip(...)`。 | 用 `default_rule` 设置兜底。 |
|
||||
| `plain:` 无效 | 不是当前 Xray routing 官方 domain 前缀。 | 使用无前缀字符串或 `keyword:`。 |
|
||||
|
||||
## 官方依据
|
||||
|
||||
| 内容 | 官方链接 |
|
||||
| --- | --- |
|
||||
| Xray RoutingObject / RuleObject | https://xtls.github.io/config/routing.html |
|
||||
| 文档源码 | https://github.com/XTLS/Xray-docs-next/blob/main/docs/config/routing.md |
|
||||
| Xray-core routing 解析代码 | https://github.com/XTLS/Xray-core/blob/main/infra/conf/router.go |
|
||||
| 域名/IP 规则解析代码 | https://github.com/XTLS/Xray-core/blob/main/common/geodata/rule_parser.go |
|
||||
@@ -0,0 +1,34 @@
|
||||
# dependencies (bun install)
|
||||
node_modules
|
||||
|
||||
# output
|
||||
out
|
||||
dist
|
||||
*.tgz
|
||||
|
||||
# code coverage
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# logs
|
||||
logs
|
||||
_.log
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# caches
|
||||
.eslintcache
|
||||
.cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# IntelliJ based IDEs
|
||||
.idea
|
||||
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
@@ -0,0 +1,21 @@
|
||||
# bun-react-tailwind-shadcn-template
|
||||
|
||||
To install dependencies:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
To start a development server:
|
||||
|
||||
```bash
|
||||
bun dev
|
||||
```
|
||||
|
||||
To run for production:
|
||||
|
||||
```bash
|
||||
bun start
|
||||
```
|
||||
|
||||
This project was created using `bun init` in bun v1.2.16. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.
|
||||
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env bun
|
||||
import { build, type BuildConfig } from "bun";
|
||||
import plugin from "bun-plugin-tailwind";
|
||||
import { existsSync } from "fs";
|
||||
import { rm } from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
// Print help text if requested
|
||||
if (process.argv.includes("--help") || process.argv.includes("-h")) {
|
||||
console.log(`
|
||||
🏗️ Bun Build Script
|
||||
|
||||
Usage: bun run build.ts [options]
|
||||
|
||||
Common Options:
|
||||
--outdir <path> Output directory (default: "dist")
|
||||
--minify Enable minification (or --minify.whitespace, --minify.syntax, etc)
|
||||
--source-map <type> Sourcemap type: none|linked|inline|external
|
||||
--target <target> Build target: browser|bun|node
|
||||
--format <format> Output format: esm|cjs|iife
|
||||
--splitting Enable code splitting
|
||||
--packages <type> Package handling: bundle|external
|
||||
--public-path <path> Public path for assets
|
||||
--env <mode> Environment handling: inline|disable|prefix*
|
||||
--conditions <list> Package.json export conditions (comma separated)
|
||||
--external <list> External packages (comma separated)
|
||||
--banner <text> Add banner text to output
|
||||
--footer <text> Add footer text to output
|
||||
--define <obj> Define global constants (e.g. --define.VERSION=1.0.0)
|
||||
--help, -h Show this help message
|
||||
|
||||
Example:
|
||||
bun run build.ts --outdir=dist --minify --source-map=linked --external=react,react-dom
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Helper function to convert kebab-case to camelCase
|
||||
const toCamelCase = (str: string): string => {
|
||||
return str.replace(/-([a-z])/g, g => g[1].toUpperCase());
|
||||
};
|
||||
|
||||
// Helper function to parse a value into appropriate type
|
||||
const parseValue = (value: string): any => {
|
||||
// Handle true/false strings
|
||||
if (value === "true") return true;
|
||||
if (value === "false") return false;
|
||||
|
||||
// Handle numbers
|
||||
if (/^\d+$/.test(value)) return parseInt(value, 10);
|
||||
if (/^\d*\.\d+$/.test(value)) return parseFloat(value);
|
||||
|
||||
// Handle arrays (comma-separated)
|
||||
if (value.includes(",")) return value.split(",").map(v => v.trim());
|
||||
|
||||
// Default to string
|
||||
return value;
|
||||
};
|
||||
|
||||
// Magical argument parser that converts CLI args to BuildConfig
|
||||
function parseArgs(): Partial<BuildConfig> {
|
||||
const config: Record<string, any> = {};
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if (!arg.startsWith("--")) continue;
|
||||
|
||||
// Handle --no-* flags
|
||||
if (arg.startsWith("--no-")) {
|
||||
const key = toCamelCase(arg.slice(5));
|
||||
config[key] = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle --flag (boolean true)
|
||||
if (!arg.includes("=") && (i === args.length - 1 || args[i + 1].startsWith("--"))) {
|
||||
const key = toCamelCase(arg.slice(2));
|
||||
config[key] = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle --key=value or --key value
|
||||
let key: string;
|
||||
let value: string;
|
||||
|
||||
if (arg.includes("=")) {
|
||||
[key, value] = arg.slice(2).split("=", 2);
|
||||
} else {
|
||||
key = arg.slice(2);
|
||||
value = args[++i];
|
||||
}
|
||||
|
||||
// Convert kebab-case key to camelCase
|
||||
key = toCamelCase(key);
|
||||
|
||||
// Handle nested properties (e.g. --minify.whitespace)
|
||||
if (key.includes(".")) {
|
||||
const [parentKey, childKey] = key.split(".");
|
||||
config[parentKey] = config[parentKey] || {};
|
||||
config[parentKey][childKey] = parseValue(value);
|
||||
} else {
|
||||
config[key] = parseValue(value);
|
||||
}
|
||||
}
|
||||
|
||||
return config as Partial<BuildConfig>;
|
||||
}
|
||||
|
||||
// Helper function to format file sizes
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
let size = bytes;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
return `${size.toFixed(2)} ${units[unitIndex]}`;
|
||||
};
|
||||
|
||||
console.log("\n🚀 Starting build process...\n");
|
||||
|
||||
// Parse CLI arguments with our magical parser
|
||||
const cliConfig = parseArgs();
|
||||
const outdir = cliConfig.outdir || path.join(process.cwd(), "dist");
|
||||
|
||||
if (existsSync(outdir)) {
|
||||
console.log(`🗑️ Cleaning previous build at ${outdir}`);
|
||||
await rm(outdir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const start = performance.now();
|
||||
|
||||
// Scan for all HTML files in the project
|
||||
const entrypoints = [...new Bun.Glob("**.html").scanSync("src")]
|
||||
.map(a => path.resolve("src", a))
|
||||
.filter(dir => !dir.includes("node_modules"));
|
||||
console.log(`📄 Found ${entrypoints.length} HTML ${entrypoints.length === 1 ? "file" : "files"} to process\n`);
|
||||
|
||||
// Build all the HTML files
|
||||
const result = await build({
|
||||
entrypoints,
|
||||
outdir,
|
||||
plugins: [plugin],
|
||||
minify: true,
|
||||
target: "browser",
|
||||
sourcemap: "linked",
|
||||
define: {
|
||||
"process.env.NODE_ENV": JSON.stringify("production"),
|
||||
},
|
||||
...cliConfig, // Merge in any CLI-provided options
|
||||
});
|
||||
|
||||
// Print the results
|
||||
const end = performance.now();
|
||||
|
||||
const outputTable = result.outputs.map(output => ({
|
||||
"File": path.relative(process.cwd(), output.path),
|
||||
"Type": output.kind,
|
||||
"Size": formatFileSize(output.size),
|
||||
}));
|
||||
|
||||
console.table(outputTable);
|
||||
const buildTime = (end - start).toFixed(2);
|
||||
|
||||
console.log(`\n✅ Build completed in ${buildTime}ms\n`);
|
||||
Vendored
+17
@@ -0,0 +1,17 @@
|
||||
// Generated by `bun init`
|
||||
|
||||
declare module "*.svg" {
|
||||
/**
|
||||
* A path to the SVG file
|
||||
*/
|
||||
const path: `${string}.svg`;
|
||||
export = path;
|
||||
}
|
||||
|
||||
declare module "*.module.css" {
|
||||
/**
|
||||
* A record of class names to their corresponding CSS module classes
|
||||
*/
|
||||
const classes: { readonly [key: string]: string };
|
||||
export = classes;
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "bun-react-template",
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^4.1.0",
|
||||
"@radix-ui/react-label": "^2.1.2",
|
||||
"@radix-ui/react-select": "^2.1.6",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"bun-plugin-tailwind": "^0.0.14",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.475.0",
|
||||
"react": "^19",
|
||||
"react-dom": "^19",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"tailwind-merge": "^3.0.1",
|
||||
"tailwindcss": "^4.0.6",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zod": "^3.24.2",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"openapi-typescript": "^7.13.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@babel/code-frame": ["@babel/code-frame@7.29.7", "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.7.tgz", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
|
||||
|
||||
"@floating-ui/core": ["@floating-ui/core@1.7.5", "https://registry.npmmirror.com/@floating-ui/core/-/core-1.7.5.tgz", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="],
|
||||
|
||||
"@floating-ui/dom": ["@floating-ui/dom@1.7.6", "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.7.6.tgz", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="],
|
||||
|
||||
"@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.8", "https://registry.npmmirror.com/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", { "dependencies": { "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A=="],
|
||||
|
||||
"@floating-ui/utils": ["@floating-ui/utils@0.2.11", "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.11.tgz", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
|
||||
|
||||
"@hookform/resolvers": ["@hookform/resolvers@4.1.3", "https://registry.npmmirror.com/@hookform/resolvers/-/resolvers-4.1.3.tgz", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.0.0" } }, "sha512-Jsv6UOWYTrEFJ/01ZrnwVXs7KDvP8XIo115i++5PWvNkNvkrsTfGiLS6w+eJ57CYtUtDQalUWovCZDHFJ8u1VQ=="],
|
||||
|
||||
"@radix-ui/number": ["@radix-ui/number@1.1.1", "https://registry.npmmirror.com/@radix-ui/number/-/number-1.1.1.tgz", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
|
||||
|
||||
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "https://registry.npmmirror.com/@radix-ui/primitive/-/primitive-1.1.3.tgz", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
|
||||
|
||||
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "https://registry.npmmirror.com/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
|
||||
|
||||
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "https://registry.npmmirror.com/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
|
||||
|
||||
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "https://registry.npmmirror.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
|
||||
|
||||
"@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "https://registry.npmmirror.com/@radix-ui/react-context/-/react-context-1.1.2.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
|
||||
|
||||
"@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "https://registry.npmmirror.com/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="],
|
||||
|
||||
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "https://registry.npmmirror.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="],
|
||||
|
||||
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "https://registry.npmmirror.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="],
|
||||
|
||||
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "https://registry.npmmirror.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="],
|
||||
|
||||
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "https://registry.npmmirror.com/@radix-ui/react-id/-/react-id-1.1.1.tgz", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
|
||||
|
||||
"@radix-ui/react-label": ["@radix-ui/react-label@2.1.8", "https://registry.npmmirror.com/@radix-ui/react-label/-/react-label-2.1.8.tgz", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A=="],
|
||||
|
||||
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "https://registry.npmmirror.com/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
|
||||
|
||||
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "https://registry.npmmirror.com/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
|
||||
|
||||
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
|
||||
|
||||
"@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "https://registry.npmmirror.com/@radix-ui/react-select/-/react-select-2.2.6.tgz", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="],
|
||||
|
||||
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
|
||||
|
||||
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "https://registry.npmmirror.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="],
|
||||
|
||||
"@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "https://registry.npmmirror.com/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="],
|
||||
|
||||
"@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "https://registry.npmmirror.com/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="],
|
||||
|
||||
"@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "https://registry.npmmirror.com/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="],
|
||||
|
||||
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "https://registry.npmmirror.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
|
||||
|
||||
"@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.1", "https://registry.npmmirror.com/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ=="],
|
||||
|
||||
"@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "https://registry.npmmirror.com/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="],
|
||||
|
||||
"@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "https://registry.npmmirror.com/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="],
|
||||
|
||||
"@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "https://registry.npmmirror.com/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="],
|
||||
|
||||
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "https://registry.npmmirror.com/@radix-ui/rect/-/rect-1.1.1.tgz", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
|
||||
|
||||
"@redocly/ajv": ["@redocly/ajv@8.11.2", "https://registry.npmmirror.com/@redocly/ajv/-/ajv-8.11.2.tgz", { "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js-replace": "^1.0.1" } }, "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg=="],
|
||||
|
||||
"@redocly/config": ["@redocly/config@0.22.0", "https://registry.npmmirror.com/@redocly/config/-/config-0.22.0.tgz", {}, "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ=="],
|
||||
|
||||
"@redocly/openapi-core": ["@redocly/openapi-core@1.34.15", "https://registry.npmmirror.com/@redocly/openapi-core/-/openapi-core-1.34.15.tgz", { "dependencies": { "@redocly/ajv": "8.11.2", "@redocly/config": "0.22.0", "colorette": "1.4.0", "https-proxy-agent": "7.0.6", "js-levenshtein": "1.1.6", "js-yaml": "4.1.1", "minimatch": "5.1.9", "pluralize": "8.0.0", "yaml-ast-parser": "0.0.43" } }, "sha512-HAwCnNyKcs5XGQqms+9t7OdAPM/5TDstmhF+0i7tdCFato2QKuYIlyWETwkXd8c5zbltr1oB+6y9NTeQLr2d6Q=="],
|
||||
|
||||
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "https://registry.npmmirror.com/@standard-schema/utils/-/utils-0.3.0.tgz", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.14", "https://registry.npmmirror.com/@types/bun/-/bun-1.3.14.tgz", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
|
||||
|
||||
"@types/node": ["@types/node@25.9.1", "https://registry.npmmirror.com/@types/node/-/node-25.9.1.tgz", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.15", "https://registry.npmmirror.com/@types/react/-/react-19.2.15.tgz", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q=="],
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.2.3", "https://registry.npmmirror.com/@types/react-dom/-/react-dom-19.2.3.tgz", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||
|
||||
"agent-base": ["agent-base@7.1.4", "https://registry.npmmirror.com/agent-base/-/agent-base-7.1.4.tgz", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||
|
||||
"ansi-colors": ["ansi-colors@4.1.3", "https://registry.npmmirror.com/ansi-colors/-/ansi-colors-4.1.3.tgz", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="],
|
||||
|
||||
"argparse": ["argparse@2.0.1", "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"aria-hidden": ["aria-hidden@1.2.6", "https://registry.npmmirror.com/aria-hidden/-/aria-hidden-1.2.6.tgz", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
|
||||
|
||||
"balanced-match": ["balanced-match@1.0.2", "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@2.1.1", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.1.tgz", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="],
|
||||
|
||||
"bun-plugin-tailwind": ["bun-plugin-tailwind@0.0.14", "https://registry.npmmirror.com/bun-plugin-tailwind/-/bun-plugin-tailwind-0.0.14.tgz", { "dependencies": { "tailwindcss": "4.0.0-beta.9" }, "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-Ge8M8DQsRDErCzH/uI8pYjx5vZWXxQvnwM/xMQMElxQqHieGbAopfYo/q/kllkPkRbFHiwhnHwTpRMAMJZCjug=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.14", "https://registry.npmmirror.com/bun-types/-/bun-types-1.3.14.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
||||
|
||||
"change-case": ["change-case@5.4.4", "https://registry.npmmirror.com/change-case/-/change-case-5.4.4.tgz", {}, "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w=="],
|
||||
|
||||
"class-variance-authority": ["class-variance-authority@0.7.1", "https://registry.npmmirror.com/class-variance-authority/-/class-variance-authority-0.7.1.tgz", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
"colorette": ["colorette@1.4.0", "https://registry.npmmirror.com/colorette/-/colorette-1.4.0.tgz", {}, "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"detect-node-es": ["detect-node-es@1.1.0", "https://registry.npmmirror.com/detect-node-es/-/detect-node-es-1.1.0.tgz", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"get-nonce": ["get-nonce@1.0.1", "https://registry.npmmirror.com/get-nonce/-/get-nonce-1.0.1.tgz", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
|
||||
|
||||
"https-proxy-agent": ["https-proxy-agent@7.0.6", "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||
|
||||
"index-to-position": ["index-to-position@1.2.0", "https://registry.npmmirror.com/index-to-position/-/index-to-position-1.2.0.tgz", {}, "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw=="],
|
||||
|
||||
"js-levenshtein": ["js-levenshtein@1.1.6", "https://registry.npmmirror.com/js-levenshtein/-/js-levenshtein-1.1.6.tgz", {}, "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"js-yaml": ["js-yaml@4.1.1", "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.1.tgz", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||
|
||||
"json-schema-traverse": ["json-schema-traverse@1.0.0", "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"lucide-react": ["lucide-react@0.475.0", "https://registry.npmmirror.com/lucide-react/-/lucide-react-0.475.0.tgz", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-NJzvVu1HwFVeZ+Gwq2q00KygM1aBhy/ZrhY9FsAgJtpB+E4R7uxRk9M2iKvHa6/vNxZydIB59htha4c2vvwvVg=="],
|
||||
|
||||
"minimatch": ["minimatch@5.1.9", "https://registry.npmmirror.com/minimatch/-/minimatch-5.1.9.tgz", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"openapi-typescript": ["openapi-typescript@7.13.0", "https://registry.npmmirror.com/openapi-typescript/-/openapi-typescript-7.13.0.tgz", { "dependencies": { "@redocly/openapi-core": "^1.34.6", "ansi-colors": "^4.1.3", "change-case": "^5.4.4", "parse-json": "^8.3.0", "supports-color": "^10.2.2", "yargs-parser": "^21.1.1" }, "peerDependencies": { "typescript": "^5.x" }, "bin": { "openapi-typescript": "bin/cli.js" } }, "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ=="],
|
||||
|
||||
"parse-json": ["parse-json@8.3.0", "https://registry.npmmirror.com/parse-json/-/parse-json-8.3.0.tgz", { "dependencies": { "@babel/code-frame": "^7.26.2", "index-to-position": "^1.1.0", "type-fest": "^4.39.1" } }, "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"pluralize": ["pluralize@8.0.0", "https://registry.npmmirror.com/pluralize/-/pluralize-8.0.0.tgz", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="],
|
||||
|
||||
"react": ["react@19.2.6", "https://registry.npmmirror.com/react/-/react-19.2.6.tgz", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="],
|
||||
|
||||
"react-dom": ["react-dom@19.2.6", "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.6.tgz", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="],
|
||||
|
||||
"react-hook-form": ["react-hook-form@7.76.1", "https://registry.npmmirror.com/react-hook-form/-/react-hook-form-7.76.1.tgz", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-rYM7tPiWlu3nZchkR/ex7piyzui2vFPyaLnXnI/RnblB/L4qfMmyses8llJVtF1NpE9WBBsJlGtcSZzPCXW1qQ=="],
|
||||
|
||||
"react-remove-scroll": ["react-remove-scroll@2.7.2", "https://registry.npmmirror.com/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
|
||||
|
||||
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "https://registry.npmmirror.com/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
|
||||
|
||||
"react-style-singleton": ["react-style-singleton@2.2.3", "https://registry.npmmirror.com/react-style-singleton/-/react-style-singleton-2.2.3.tgz", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
|
||||
|
||||
"require-from-string": ["require-from-string@2.0.2", "https://registry.npmmirror.com/require-from-string/-/require-from-string-2.0.2.tgz", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||
|
||||
"scheduler": ["scheduler@0.27.0", "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"supports-color": ["supports-color@10.2.2", "https://registry.npmmirror.com/supports-color/-/supports-color-10.2.2.tgz", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
|
||||
|
||||
"tailwind-merge": ["tailwind-merge@3.6.0", "https://registry.npmmirror.com/tailwind-merge/-/tailwind-merge-3.6.0.tgz", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.3.0", "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-4.3.0.tgz", {}, "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q=="],
|
||||
|
||||
"tailwindcss-animate": ["tailwindcss-animate@1.0.7", "https://registry.npmmirror.com/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz", { "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders" } }, "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"type-fest": ["type-fest@4.41.0", "https://registry.npmmirror.com/type-fest/-/type-fest-4.41.0.tgz", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.24.6", "https://registry.npmmirror.com/undici-types/-/undici-types-7.24.6.tgz", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
|
||||
|
||||
"uri-js-replace": ["uri-js-replace@1.0.1", "https://registry.npmmirror.com/uri-js-replace/-/uri-js-replace-1.0.1.tgz", {}, "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g=="],
|
||||
|
||||
"use-callback-ref": ["use-callback-ref@1.3.3", "https://registry.npmmirror.com/use-callback-ref/-/use-callback-ref-1.3.3.tgz", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
|
||||
|
||||
"use-sidecar": ["use-sidecar@1.1.3", "https://registry.npmmirror.com/use-sidecar/-/use-sidecar-1.1.3.tgz", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
|
||||
|
||||
"yaml-ast-parser": ["yaml-ast-parser@0.0.43", "https://registry.npmmirror.com/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", {}, "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A=="],
|
||||
|
||||
"yargs-parser": ["yargs-parser@21.1.1", "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-21.1.1.tgz", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
|
||||
|
||||
"zod": ["zod@3.25.76", "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"@radix-ui/react-arrow/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-collection/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-dismissable-layer/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-focus-scope/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-popper/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-portal/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-select/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-visually-hidden/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"bun-plugin-tailwind/tailwindcss": ["tailwindcss@4.0.0-beta.9", "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-4.0.0-beta.9.tgz", {}, "sha512-96KpsfQi+/sFIOfyFnGzyy5pobuzf1iMBD9NVtelerPM/lPI2XUS4Kikw9yuKRniXXw77ov1sl7gCSKLsn6CJA=="],
|
||||
|
||||
"@radix-ui/react-arrow/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-dismissable-layer/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-focus-scope/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-popper/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-portal/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-visually-hidden/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
[serve.static]
|
||||
plugins = ["bun-plugin-tailwind"]
|
||||
env = "BUN_PUBLIC_*"
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "styles/globals.css",
|
||||
"baseColor": "zinc",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "bun-react-template",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.tsx",
|
||||
"module": "src/index.tsx",
|
||||
"scripts": {
|
||||
"dev": "bun --hot src/index.tsx",
|
||||
"start": "NODE_ENV=production bun src/index.tsx",
|
||||
"build": "bun run build.ts",
|
||||
"generate:api": "openapi-typescript ../protocol/openapi/assets.openapi.yaml -o src/api/generated/assets.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^4.1.0",
|
||||
"@radix-ui/react-label": "^2.1.2",
|
||||
"@radix-ui/react-select": "^2.1.6",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"bun-plugin-tailwind": "^0.0.14",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.475.0",
|
||||
"react": "^19",
|
||||
"react-dom": "^19",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"tailwind-merge": "^3.0.1",
|
||||
"tailwindcss": "^4.0.6",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"openapi-typescript": "^7.13.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import "./index.css";
|
||||
|
||||
import { AssetsPage } from "@/components/assets/AssetsPage";
|
||||
|
||||
export function App() {
|
||||
return <AssetsPage />;
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,41 @@
|
||||
import { apiRequest } from "./client";
|
||||
import type { components } from "./generated/assets";
|
||||
|
||||
export type CoreConfigText = components["schemas"]["CoreConfigText"];
|
||||
export type AssetsStatus = components["schemas"]["AssetsStatus"];
|
||||
export type AssetsDownloadRequest = components["schemas"]["AssetsDownloadRequest"];
|
||||
export type AssetsUpdateRequest = components["schemas"]["AssetsUpdateRequest"];
|
||||
export type AssetsTaskStartResponse = components["schemas"]["AssetsTaskStartResponse"];
|
||||
export type DownloadTaskStatus = components["schemas"]["DownloadTaskStatus"];
|
||||
|
||||
export function getAssetsConfig() {
|
||||
return apiRequest<CoreConfigText>("/assets/config");
|
||||
}
|
||||
|
||||
export function getAssetsStatus() {
|
||||
return apiRequest<AssetsStatus>("/assets/status");
|
||||
}
|
||||
|
||||
export function getDownloadTask() {
|
||||
return apiRequest<DownloadTaskStatus>("/assets/download/task");
|
||||
}
|
||||
|
||||
export function downloadAssets(request: AssetsDownloadRequest) {
|
||||
return apiRequest<AssetsTaskStartResponse>("/assets/download", {
|
||||
method: "POST",
|
||||
body: request,
|
||||
});
|
||||
}
|
||||
|
||||
export function updateAssets(request: AssetsUpdateRequest) {
|
||||
return apiRequest<AssetsTaskStartResponse>("/assets/update", {
|
||||
method: "POST",
|
||||
body: request,
|
||||
});
|
||||
}
|
||||
|
||||
export function cancelDownload() {
|
||||
return apiRequest<DownloadTaskStatus>("/assets/download/cancel", {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
const API_BASE_URL = "/api/v1";
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
}
|
||||
}
|
||||
|
||||
type RequestOptions = {
|
||||
method?: string;
|
||||
body?: unknown;
|
||||
};
|
||||
|
||||
export async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
method: options.method ?? "GET",
|
||||
headers: options.body ? { "content-type": "application/json" } : undefined,
|
||||
body: options.body ? JSON.stringify(options.body) : undefined,
|
||||
});
|
||||
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
const message = typeof data?.error === "string" ? data.error : `HTTP ${response.status}`;
|
||||
throw new ApiError(message, response.status);
|
||||
}
|
||||
|
||||
return data as T;
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
/**
|
||||
* This file was auto-generated by openapi-typescript.
|
||||
* Do not make direct changes to the file.
|
||||
*/
|
||||
|
||||
export interface paths {
|
||||
"/assets/config": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Read core.toml content */
|
||||
get: operations["getAssetsConfig"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/assets/status": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Inspect local Xray and geo assets */
|
||||
get: operations["getAssetsStatus"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/assets/update": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Update assets when needed */
|
||||
post: operations["updateAssets"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/assets/download": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Download selected assets */
|
||||
post: operations["downloadAssets"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/assets/download/task": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Read current download task status */
|
||||
get: operations["getAssetsDownloadTask"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/assets/download/cancel": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Request cancellation for the current download task */
|
||||
post: operations["cancelAssetsDownload"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
}
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
schemas: {
|
||||
CoreConfigText: {
|
||||
path: string;
|
||||
content: string;
|
||||
};
|
||||
FileStatus: {
|
||||
exists: boolean;
|
||||
path: string;
|
||||
size: number | null;
|
||||
};
|
||||
AssetsStatus: {
|
||||
installed: boolean;
|
||||
executable_path: string;
|
||||
version: string;
|
||||
healthy: boolean;
|
||||
geoip: components["schemas"]["FileStatus"];
|
||||
geosite: components["schemas"]["FileStatus"];
|
||||
};
|
||||
AssetsUpdateRequest: {
|
||||
/** @default false */
|
||||
force: boolean;
|
||||
/** @default */
|
||||
proxy_url: string;
|
||||
};
|
||||
/** @enum {string} */
|
||||
DownloadState: "idle" | "running" | "completed" | "canceled" | "failed";
|
||||
/** @enum {string} */
|
||||
DownloadKind: "xray" | "geo" | "geoip" | "geosite";
|
||||
AssetsTaskStartResponse: {
|
||||
task_id: string;
|
||||
state: components["schemas"]["DownloadState"];
|
||||
items: components["schemas"]["DownloadKind"][];
|
||||
};
|
||||
ErrorResponse: {
|
||||
error: string;
|
||||
};
|
||||
AssetsDownloadRequest: {
|
||||
/** @default */
|
||||
xray_url: string;
|
||||
/** @default */
|
||||
geo_url: string;
|
||||
/** @default */
|
||||
geoip_url: string;
|
||||
/** @default */
|
||||
geosite_url: string;
|
||||
/** @default false */
|
||||
force: boolean;
|
||||
/** @default */
|
||||
proxy_url: string;
|
||||
};
|
||||
DownloadTaskStatus: {
|
||||
state: components["schemas"]["DownloadState"];
|
||||
kind: components["schemas"]["DownloadKind"] | null;
|
||||
url: string;
|
||||
target: string;
|
||||
total_bytes: number | null;
|
||||
downloaded_bytes: number;
|
||||
progress: number | null;
|
||||
error: string;
|
||||
};
|
||||
};
|
||||
responses: never;
|
||||
parameters: never;
|
||||
requestBodies: never;
|
||||
headers: never;
|
||||
pathItems: never;
|
||||
}
|
||||
export type $defs = Record<string, never>;
|
||||
export interface operations {
|
||||
getAssetsConfig: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Core configuration as TOML text. */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["CoreConfigText"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getAssetsStatus: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Local asset status. */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["AssetsStatus"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
updateAssets: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["AssetsUpdateRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Download task accepted. */
|
||||
202: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["AssetsTaskStartResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Another download task is already running. */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
downloadAssets: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["AssetsDownloadRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Download task accepted. */
|
||||
202: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["AssetsTaskStartResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Another download task is already running. */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getAssetsDownloadTask: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Current download task status. */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["DownloadTaskStatus"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
cancelAssetsDownload: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Current download task status after cancellation was requested. */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["DownloadTaskStatus"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { AssetsStatus } from "@/api/assets";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { formatBytes } from "./format";
|
||||
|
||||
export function AssetStatusGrid({ status }: { status: AssetsStatus | null }) {
|
||||
const files = [
|
||||
{
|
||||
name: "xray",
|
||||
exists: status?.installed ?? false,
|
||||
path: status?.executable_path ?? "",
|
||||
version: status?.version,
|
||||
},
|
||||
{
|
||||
name: "geoip.dat",
|
||||
exists: status?.geoip.exists ?? false,
|
||||
path: status?.geoip.path ?? "",
|
||||
size: status?.geoip.size,
|
||||
},
|
||||
{
|
||||
name: "geosite.dat",
|
||||
exists: status?.geosite.exists ?? false,
|
||||
path: status?.geosite.path ?? "",
|
||||
size: status?.geosite.size,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
{files.map((file) => (
|
||||
<Card key={file.name} className="rounded-2xl bg-card/85 shadow-sm">
|
||||
<CardContent className="p-4">
|
||||
<div className="font-mono text-sm">{file.name}</div>
|
||||
<div className={cn("mt-2 text-sm", file.exists ? "text-emerald-600" : "text-red-600")}>
|
||||
{file.exists ? "存在" : "缺失"}
|
||||
</div>
|
||||
<div className="mt-3 truncate text-xs text-muted-foreground" title={file.path}>
|
||||
{file.path || "等待检测"}
|
||||
</div>
|
||||
{"version" in file && file.version ? (
|
||||
<div className="mt-2 font-mono text-xs text-muted-foreground">{file.version}</div>
|
||||
) : null}
|
||||
{"size" in file && typeof file.size === "number" ? (
|
||||
<div className="mt-2 font-mono text-xs text-muted-foreground">{formatBytes(file.size)}</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
|
||||
import {
|
||||
cancelDownload,
|
||||
downloadAssets,
|
||||
getAssetsConfig,
|
||||
getAssetsStatus,
|
||||
getDownloadTask,
|
||||
updateAssets,
|
||||
type AssetsStatus,
|
||||
type CoreConfigText,
|
||||
type DownloadTaskStatus,
|
||||
} from "@/api/assets";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { AssetStatusGrid } from "./AssetStatusGrid";
|
||||
import { CoreConfigCard } from "./CoreConfigCard";
|
||||
import { DownloadForm } from "./DownloadForm";
|
||||
import { TaskCard } from "./TaskCard";
|
||||
import { errorMessage } from "./format";
|
||||
import type { DownloadTarget } from "./types";
|
||||
|
||||
const initialTask: DownloadTaskStatus = {
|
||||
state: "idle",
|
||||
kind: null,
|
||||
url: "",
|
||||
target: "",
|
||||
total_bytes: null,
|
||||
downloaded_bytes: 0,
|
||||
progress: null,
|
||||
error: "",
|
||||
};
|
||||
|
||||
export function AssetsPage() {
|
||||
const [config, setConfig] = useState<CoreConfigText | null>(null);
|
||||
const [status, setStatus] = useState<AssetsStatus | null>(null);
|
||||
const [task, setTask] = useState<DownloadTaskStatus>(initialTask);
|
||||
const [proxyUrl, setProxyUrl] = useState("");
|
||||
const [xrayUrl, setXrayUrl] = useState("");
|
||||
const [geoipUrl, setGeoipUrl] = useState("");
|
||||
const [geositeUrl, setGeositeUrl] = useState("");
|
||||
const [force, setForce] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const isRunning = task.state === "running";
|
||||
|
||||
async function refresh() {
|
||||
const [nextConfig, nextStatus, nextTask] = await Promise.all([
|
||||
getAssetsConfig(),
|
||||
getAssetsStatus(),
|
||||
getDownloadTask(),
|
||||
]);
|
||||
startTransition(() => {
|
||||
setConfig(nextConfig);
|
||||
setStatus(nextStatus);
|
||||
setTask(nextTask);
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh().catch((reason) => setError(errorMessage(reason)));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRunning) return;
|
||||
const id = window.setInterval(async () => {
|
||||
try {
|
||||
const nextTask = await getDownloadTask();
|
||||
setTask(nextTask);
|
||||
if (nextTask.state !== "running") {
|
||||
await refresh();
|
||||
}
|
||||
} catch (reason) {
|
||||
setError(errorMessage(reason));
|
||||
}
|
||||
}, 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [isRunning]);
|
||||
|
||||
async function submitDownload(target: DownloadTarget) {
|
||||
setError("");
|
||||
try {
|
||||
await downloadAssets({
|
||||
xray_url: target === "all" || target === "xray" ? xrayUrl : "",
|
||||
geo_url: "",
|
||||
geoip_url: target === "all" || target === "geoip" ? geoipUrl : "",
|
||||
geosite_url: target === "all" || target === "geosite" ? geositeUrl : "",
|
||||
force,
|
||||
proxy_url: proxyUrl,
|
||||
});
|
||||
setTask(await getDownloadTask());
|
||||
} catch (reason) {
|
||||
setError(errorMessage(reason));
|
||||
}
|
||||
}
|
||||
|
||||
async function submitUpdate() {
|
||||
setError("");
|
||||
try {
|
||||
await updateAssets({ force, proxy_url: proxyUrl });
|
||||
setTask(await getDownloadTask());
|
||||
} catch (reason) {
|
||||
setError(errorMessage(reason));
|
||||
}
|
||||
}
|
||||
|
||||
async function stopDownload() {
|
||||
setError("");
|
||||
try {
|
||||
setTask(await cancelDownload());
|
||||
} catch (reason) {
|
||||
setError(errorMessage(reason));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[radial-gradient(circle_at_top_left,hsl(48_22%_88%),transparent_34%),linear-gradient(135deg,hsl(44_24%_96%),hsl(210_18%_91%))] px-4 py-6 text-foreground md:px-8 lg:px-10">
|
||||
<div className="mx-auto grid max-w-7xl gap-6">
|
||||
<header className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<p className="font-mono text-xs uppercase tracking-[0.28em] text-muted-foreground">
|
||||
pyxray assets
|
||||
</p>
|
||||
<h1 className="mt-2 text-4xl font-semibold tracking-tight">资源下载</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">xray / geoip.dat / geosite.dat</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => refresh().catch((reason) => setError(errorMessage(reason)))}>
|
||||
<RefreshCw className={cn(isPending && "animate-spin")} />
|
||||
刷新
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
{error ? (
|
||||
<Card className="border-destructive/30 bg-destructive/10">
|
||||
<CardContent className="pt-6 text-sm text-destructive">{error}</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<section className="grid gap-6 lg:grid-cols-[1.05fr_0.95fr]">
|
||||
<div className="grid content-start gap-6">
|
||||
<AssetStatusGrid status={status} />
|
||||
<DownloadForm
|
||||
force={force}
|
||||
geositeUrl={geositeUrl}
|
||||
geoipUrl={geoipUrl}
|
||||
isRunning={isRunning}
|
||||
proxyUrl={proxyUrl}
|
||||
xrayUrl={xrayUrl}
|
||||
onDownload={submitDownload}
|
||||
onForceChange={setForce}
|
||||
onGeositeUrlChange={setGeositeUrl}
|
||||
onGeoipUrlChange={setGeoipUrl}
|
||||
onProxyUrlChange={setProxyUrl}
|
||||
onUpdate={submitUpdate}
|
||||
onXrayUrlChange={setXrayUrl}
|
||||
/>
|
||||
<CoreConfigCard config={config} />
|
||||
</div>
|
||||
|
||||
<aside className="grid content-start gap-5">
|
||||
<TaskCard isRunning={isRunning} task={task} onCancel={stopDownload} />
|
||||
</aside>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { CoreConfigText } from "@/api/assets";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
export function CoreConfigCard({ config }: { config: CoreConfigText | null }) {
|
||||
return (
|
||||
<Card className="rounded-3xl bg-card/90">
|
||||
<CardHeader>
|
||||
<CardTitle>core.toml</CardTitle>
|
||||
<CardDescription className="truncate">{config?.path ?? "等待加载"}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="max-h-72 overflow-auto rounded-2xl border bg-muted/40 p-4 font-mono text-xs leading-6">
|
||||
{config?.content ?? "loading..."}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Zap } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
import type { DownloadTarget } from "./types";
|
||||
|
||||
type DownloadFormProps = {
|
||||
force: boolean;
|
||||
geositeUrl: string;
|
||||
geoipUrl: string;
|
||||
isRunning: boolean;
|
||||
proxyUrl: string;
|
||||
xrayUrl: string;
|
||||
onDownload: (target: DownloadTarget) => void;
|
||||
onForceChange: (value: boolean) => void;
|
||||
onGeositeUrlChange: (value: string) => void;
|
||||
onGeoipUrlChange: (value: string) => void;
|
||||
onProxyUrlChange: (value: string) => void;
|
||||
onUpdate: () => void;
|
||||
onXrayUrlChange: (value: string) => void;
|
||||
};
|
||||
|
||||
export function DownloadForm(props: DownloadFormProps) {
|
||||
return (
|
||||
<Card className="rounded-3xl bg-card/90">
|
||||
<CardHeader>
|
||||
<CardTitle>下载控制</CardTitle>
|
||||
<CardDescription>空地址会使用 core.toml 或默认 Xray release 方案。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4">
|
||||
<Field label="下载代理">
|
||||
<Input
|
||||
placeholder="例如 http://127.0.0.1:7890"
|
||||
value={props.proxyUrl}
|
||||
onChange={(event) => props.onProxyUrlChange(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Release Zip 地址">
|
||||
<Input
|
||||
placeholder="空则使用官方 Xray-core release zip"
|
||||
value={props.xrayUrl}
|
||||
onChange={(event) => props.onXrayUrlChange(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="geoip.dat 地址">
|
||||
<Input
|
||||
placeholder="空则使用 zip 内置 geoip.dat"
|
||||
value={props.geoipUrl}
|
||||
onChange={(event) => props.onGeoipUrlChange(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="geosite.dat 地址">
|
||||
<Input
|
||||
placeholder="空则使用 zip 内置 geosite.dat"
|
||||
value={props.geositeUrl}
|
||||
onChange={(event) => props.onGeositeUrlChange(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Label className="flex items-center gap-3 rounded-2xl border bg-muted/40 px-4 py-3">
|
||||
<input
|
||||
checked={props.force}
|
||||
className="size-4 accent-foreground"
|
||||
type="checkbox"
|
||||
onChange={(event) => props.onForceChange(event.target.checked)}
|
||||
/>
|
||||
<span className="text-sm">重新下载并覆盖已有文件</span>
|
||||
</Label>
|
||||
<div className="grid gap-3 pt-2 sm:grid-cols-2 xl:grid-cols-5">
|
||||
<Button disabled={props.isRunning} onClick={() => props.onDownload("all")}>
|
||||
<Zap />
|
||||
下载所有
|
||||
</Button>
|
||||
<Button disabled={props.isRunning} variant="secondary" onClick={props.onUpdate}>
|
||||
自动更新
|
||||
</Button>
|
||||
<Button disabled={props.isRunning} variant="outline" onClick={() => props.onDownload("xray")}>
|
||||
下载 xray
|
||||
</Button>
|
||||
<Button disabled={props.isRunning} variant="outline" onClick={() => props.onDownload("geoip")}>
|
||||
下载 geoip
|
||||
</Button>
|
||||
<Button disabled={props.isRunning} variant="outline" onClick={() => props.onDownload("geosite")}>
|
||||
下载 geosite
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ children, label }: { children: ReactNode; label: string }) {
|
||||
return (
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-sm text-muted-foreground">{label}</span>
|
||||
{children}
|
||||
</Label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Loader2, Square } from "lucide-react";
|
||||
|
||||
import type { DownloadTaskStatus } from "@/api/assets";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
import { formatBytes, stateText } from "./format";
|
||||
|
||||
export function TaskCard({
|
||||
isRunning,
|
||||
task,
|
||||
onCancel,
|
||||
}: {
|
||||
isRunning: boolean;
|
||||
task: DownloadTaskStatus;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const progress = task.progress ?? (task.total_bytes ? task.downloaded_bytes / task.total_bytes : 0);
|
||||
const percent = Math.round(Math.max(0, Math.min(progress, 1)) * 100);
|
||||
|
||||
return (
|
||||
<Card className="rounded-3xl bg-card/90">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>当前进度</CardTitle>
|
||||
<CardDescription>{task.kind ?? "等待操作"}</CardDescription>
|
||||
</div>
|
||||
<span className="rounded-full border px-3 py-1 text-sm text-muted-foreground">{stateText(task.state)}</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-5">
|
||||
<div>
|
||||
<div className="h-3 overflow-hidden rounded-full bg-muted">
|
||||
<div className="h-full rounded-full bg-foreground transition-all" style={{ width: `${percent}%` }} />
|
||||
</div>
|
||||
<div className="mt-3 font-mono text-xs text-muted-foreground">
|
||||
{percent}% · {formatBytes(task.downloaded_bytes)}
|
||||
{task.total_bytes ? ` / ${formatBytes(task.total_bytes)}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 rounded-2xl border bg-muted/30 p-4 text-sm">
|
||||
<Meta label="URL" value={task.url || "无"} />
|
||||
<Meta label="Target" value={task.target || "无"} />
|
||||
<Meta label="Error" value={task.error || "无"} />
|
||||
</div>
|
||||
{isRunning ? (
|
||||
<Button variant="destructive" onClick={onCancel}>
|
||||
<Square />
|
||||
停止下载
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4" />
|
||||
等待点击下载按钮。
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Meta({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
<div className="mt-1 break-all font-mono text-xs">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { DownloadTaskStatus } from "@/api/assets";
|
||||
|
||||
export function stateText(state: DownloadTaskStatus["state"]) {
|
||||
const values = {
|
||||
idle: "等待操作",
|
||||
running: "下载中",
|
||||
completed: "已完成",
|
||||
canceled: "已取消",
|
||||
failed: "失败",
|
||||
};
|
||||
return values[state];
|
||||
}
|
||||
|
||||
export function formatBytes(value: number) {
|
||||
if (!Number.isFinite(value)) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
let size = value;
|
||||
let index = 0;
|
||||
while (size >= 1024 && index < units.length - 1) {
|
||||
size /= 1024;
|
||||
index += 1;
|
||||
}
|
||||
return `${size.toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
|
||||
}
|
||||
|
||||
export function errorMessage(reason: unknown) {
|
||||
return reason instanceof Error ? reason.message : "未知错误";
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export type DownloadTarget = "all" | "xray" | "geoip" | "geosite";
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 focus-visible:ring-4 focus-visible:outline-1 aria-invalid:focus-visible:ring-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground shadow-sm hover:bg-primary/90",
|
||||
destructive: "bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90",
|
||||
outline: "border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
|
||||
secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return <Comp data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} />;
|
||||
}
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -0,0 +1,37 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn("bg-card text-card-foreground rounded-xl border shadow-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div data-slot="card-header" className={cn("flex flex-col gap-1.5 p-6", className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div data-slot="card-title" className={cn("leading-none font-semibold tracking-tight", className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div data-slot="card-description" className={cn("text-muted-foreground text-sm", className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div data-slot="card-content" className={cn("p-6 pt-0", className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div data-slot="card-footer" className={cn("flex items-center p-6 pt-0", className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle };
|
||||
@@ -0,0 +1,141 @@
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import * as React from "react";
|
||||
import {
|
||||
Controller,
|
||||
ControllerProps,
|
||||
FieldPath,
|
||||
FieldValues,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
useFormState,
|
||||
} from "react-hook-form";
|
||||
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Form = FormProvider;
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = {
|
||||
name: TName;
|
||||
};
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>({} as FormFieldContextValue);
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext);
|
||||
const itemContext = React.useContext(FormItemContext);
|
||||
const { getFieldState } = useFormContext();
|
||||
const formState = useFormState({ name: fieldContext.name });
|
||||
const fieldState = getFieldState(fieldContext.name, formState);
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>");
|
||||
}
|
||||
|
||||
const { id } = itemContext;
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
};
|
||||
};
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>({} as FormItemContextValue);
|
||||
|
||||
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const id = React.useId();
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div data-slot="form-item" className={cn("grid gap-2", className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function FormLabel({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
const { error, formItemId } = useFormField();
|
||||
|
||||
return (
|
||||
<Label
|
||||
data-slot="form-label"
|
||||
data-error={!!error}
|
||||
className={cn("data-[error=true]:text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
|
||||
|
||||
return (
|
||||
<Slot
|
||||
data-slot="form-control"
|
||||
id={formItemId}
|
||||
aria-describedby={!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { formDescriptionId } = useFormField();
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-description"
|
||||
id={formDescriptionId}
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { error, formMessageId } = useFormField();
|
||||
const body = error ? String(error?.message) : props.children;
|
||||
|
||||
if (!body) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-message"
|
||||
id={formMessageId}
|
||||
className={cn("text-destructive text-sm font-medium", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, useFormField };
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"border-input file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground aria-invalid:outline-destructive/60 aria-invalid:ring-destructive/20 dark:aria-invalid:outline-destructive dark:aria-invalid:ring-destructive/50 ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 aria-invalid:outline-destructive/60 dark:aria-invalid:outline-destructive dark:aria-invalid:ring-destructive/40 aria-invalid:ring-destructive/20 aria-invalid:border-destructive/60 dark:aria-invalid:border-destructive flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-4 focus-visible:outline-1 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:focus-visible:ring-[3px] aria-invalid:focus-visible:outline-none md:text-sm dark:aria-invalid:focus-visible:ring-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Input };
|
||||
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Label };
|
||||
@@ -0,0 +1,150 @@
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||
}
|
||||
|
||||
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||
}
|
||||
|
||||
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||
}
|
||||
|
||||
function SelectTrigger({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Trigger>) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground aria-invalid:border-destructive ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex h-9 w-full items-center justify-between rounded-md border bg-transparent px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] focus-visible:ring-4 focus-visible:outline-1 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:focus-visible:ring-0 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&>span]:line-clamp-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectItem({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectSeparator({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* This file is the entry point for the React app, it sets up the root
|
||||
* element and renders the App component to the DOM.
|
||||
*
|
||||
* It is included in `src/index.html`.
|
||||
*/
|
||||
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
|
||||
const elem = document.getElementById("root")!;
|
||||
const app = (
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
|
||||
if (import.meta.hot) {
|
||||
// With hot module reloading, `import.meta.hot.data` is persisted.
|
||||
const root = (import.meta.hot.data.root ??= createRoot(elem));
|
||||
root.render(app);
|
||||
} else {
|
||||
// The hot module reloading API is not available in production.
|
||||
createRoot(elem).render(app);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
@import "../styles/globals.css";
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
@apply font-sans;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply min-w-[320px] min-h-screen m-0 bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion) {
|
||||
*,
|
||||
::before,
|
||||
::after {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>pyxray assets</title>
|
||||
<script type="module" src="./frontend.tsx" async></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,32 @@
|
||||
import { serve } from "bun";
|
||||
import index from "./index.html";
|
||||
|
||||
const apiTarget = process.env.PYXRAY_API_TARGET || "http://127.0.0.1:8000";
|
||||
|
||||
const server = serve({
|
||||
routes: {
|
||||
// Serve index.html for all unmatched routes.
|
||||
"/*": index,
|
||||
|
||||
"/api/*": async req => {
|
||||
const url = new URL(req.url);
|
||||
const target = new URL(url.pathname + url.search, apiTarget);
|
||||
return fetch(target, {
|
||||
method: req.method,
|
||||
headers: req.headers,
|
||||
body: req.body,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
development: process.env.NODE_ENV !== "production" && {
|
||||
// Enable browser hot reloading in development
|
||||
hmr: true,
|
||||
|
||||
// Echo console logs from the browser to the server
|
||||
console: true,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`Server running at ${server.url}`);
|
||||
console.log(`API proxy target: ${apiTarget}`);
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg id="Bun" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 70"><title>Bun Logo</title><path id="Shadow" d="M71.09,20.74c-.16-.17-.33-.34-.5-.5s-.33-.34-.5-.5-.33-.34-.5-.5-.33-.34-.5-.5-.33-.34-.5-.5-.33-.34-.5-.5-.33-.34-.5-.5A26.46,26.46,0,0,1,75.5,35.7c0,16.57-16.82,30.05-37.5,30.05-11.58,0-21.94-4.23-28.83-10.86l.5.5.5.5.5.5.5.5.5.5.5.5.5.5C19.55,65.3,30.14,69.75,42,69.75c20.68,0,37.5-13.48,37.5-30C79.5,32.69,76.46,26,71.09,20.74Z"/><g id="Body"><path id="Background" d="M73,35.7c0,15.21-15.67,27.54-35,27.54S3,50.91,3,35.7C3,26.27,9,17.94,18.22,13S33.18,3,38,3s8.94,4.13,19.78,10C67,17.94,73,26.27,73,35.7Z" style="fill:#fbf0df"/><path id="Bottom_Shadow" data-name="Bottom Shadow" d="M73,35.7a21.67,21.67,0,0,0-.8-5.78c-2.73,33.3-43.35,34.9-59.32,24.94A40,40,0,0,0,38,63.24C57.3,63.24,73,50.89,73,35.7Z" style="fill:#f6dece"/><path id="Light_Shine" data-name="Light Shine" d="M24.53,11.17C29,8.49,34.94,3.46,40.78,3.45A9.29,9.29,0,0,0,38,3c-2.42,0-5,1.25-8.25,3.13-1.13.66-2.3,1.39-3.54,2.15-2.33,1.44-5,3.07-8,4.7C8.69,18.13,3,26.62,3,35.7c0,.4,0,.8,0,1.19C9.06,15.48,20.07,13.85,24.53,11.17Z" style="fill:#fffefc"/><path id="Top" d="M35.12,5.53A16.41,16.41,0,0,1,29.49,18c-.28.25-.06.73.3.59,3.37-1.31,7.92-5.23,6-13.14C35.71,5,35.12,5.12,35.12,5.53Zm2.27,0A16.24,16.24,0,0,1,39,19c-.12.35.31.65.55.36C41.74,16.56,43.65,11,37.93,5,37.64,4.74,37.19,5.14,37.39,5.49Zm2.76-.17A16.42,16.42,0,0,1,47,17.12a.33.33,0,0,0,.65.11c.92-3.49.4-9.44-7.17-12.53C40.08,4.54,39.82,5.08,40.15,5.32ZM21.69,15.76a16.94,16.94,0,0,0,10.47-9c.18-.36.75-.22.66.18-1.73,8-7.52,9.67-11.12,9.45C21.32,16.4,21.33,15.87,21.69,15.76Z" style="fill:#ccbea7;fill-rule:evenodd"/><path id="Outline" d="M38,65.75C17.32,65.75.5,52.27.5,35.7c0-10,6.18-19.33,16.53-24.92,3-1.6,5.57-3.21,7.86-4.62,1.26-.78,2.45-1.51,3.6-2.19C32,1.89,35,.5,38,.5s5.62,1.2,8.9,3.14c1,.57,2,1.19,3.07,1.87,2.49,1.54,5.3,3.28,9,5.27C69.32,16.37,75.5,25.69,75.5,35.7,75.5,52.27,58.68,65.75,38,65.75ZM38,3c-2.42,0-5,1.25-8.25,3.13-1.13.66-2.3,1.39-3.54,2.15-2.33,1.44-5,3.07-8,4.7C8.69,18.13,3,26.62,3,35.7,3,50.89,18.7,63.25,38,63.25S73,50.89,73,35.7C73,26.62,67.31,18.13,57.78,13,54,11,51.05,9.12,48.66,7.64c-1.09-.67-2.09-1.29-3-1.84C42.63,4,40.42,3,38,3Z"/></g><g id="Mouth"><g id="Background-2" data-name="Background"><path d="M45.05,43a8.93,8.93,0,0,1-2.92,4.71,6.81,6.81,0,0,1-4,1.88A6.84,6.84,0,0,1,34,47.71,8.93,8.93,0,0,1,31.12,43a.72.72,0,0,1,.8-.81H44.26A.72.72,0,0,1,45.05,43Z" style="fill:#b71422"/></g><g id="Tongue"><path id="Background-3" data-name="Background" d="M34,47.79a6.91,6.91,0,0,0,4.12,1.9,6.91,6.91,0,0,0,4.11-1.9,10.63,10.63,0,0,0,1-1.07,6.83,6.83,0,0,0-4.9-2.31,6.15,6.15,0,0,0-5,2.78C33.56,47.4,33.76,47.6,34,47.79Z" style="fill:#ff6164"/><path id="Outline-2" data-name="Outline" d="M34.16,47a5.36,5.36,0,0,1,4.19-2.08,6,6,0,0,1,4,1.69c.23-.25.45-.51.66-.77a7,7,0,0,0-4.71-1.93,6.36,6.36,0,0,0-4.89,2.36A9.53,9.53,0,0,0,34.16,47Z"/></g><path id="Outline-3" data-name="Outline" d="M38.09,50.19a7.42,7.42,0,0,1-4.45-2,9.52,9.52,0,0,1-3.11-5.05,1.2,1.2,0,0,1,.26-1,1.41,1.41,0,0,1,1.13-.51H44.26a1.44,1.44,0,0,1,1.13.51,1.19,1.19,0,0,1,.25,1h0a9.52,9.52,0,0,1-3.11,5.05A7.42,7.42,0,0,1,38.09,50.19Zm-6.17-7.4c-.16,0-.2.07-.21.09a8.29,8.29,0,0,0,2.73,4.37A6.23,6.23,0,0,0,38.09,49a6.28,6.28,0,0,0,3.65-1.73,8.3,8.3,0,0,0,2.72-4.37.21.21,0,0,0-.2-.09Z"/></g><g id="Face"><ellipse id="Right_Blush" data-name="Right Blush" cx="53.22" cy="40.18" rx="5.85" ry="3.44" style="fill:#febbd0"/><ellipse id="Left_Bluch" data-name="Left Bluch" cx="22.95" cy="40.18" rx="5.85" ry="3.44" style="fill:#febbd0"/><path id="Eyes" d="M25.7,38.8a5.51,5.51,0,1,0-5.5-5.51A5.51,5.51,0,0,0,25.7,38.8Zm24.77,0A5.51,5.51,0,1,0,45,33.29,5.5,5.5,0,0,0,50.47,38.8Z" style="fill-rule:evenodd"/><path id="Iris" d="M24,33.64a2.07,2.07,0,1,0-2.06-2.07A2.07,2.07,0,0,0,24,33.64Zm24.77,0a2.07,2.07,0,1,0-2.06-2.07A2.07,2.07,0,0,0,48.75,33.64Z" style="fill:#fff;fill-rule:evenodd"/></g></svg>
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-11.5 -10.23174 23 20.46348">
|
||||
<circle cx="0" cy="0" r="2.05" fill="#61dafb"/>
|
||||
<g stroke="#61dafb" stroke-width="1" fill="none">
|
||||
<ellipse rx="11" ry="4.2"/>
|
||||
<ellipse rx="11" ry="4.2" transform="rotate(60)"/>
|
||||
<ellipse rx="11" ry="4.2" transform="rotate(120)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 338 B |
@@ -0,0 +1,144 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@plugin "tailwindcss-animate";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--background: hsl(0 0% 100%);
|
||||
--foreground: hsl(240 10% 3.9%);
|
||||
--card: hsl(0 0% 100%);
|
||||
--card-foreground: hsl(240 10% 3.9%);
|
||||
--popover: hsl(0 0% 100%);
|
||||
--popover-foreground: hsl(240 10% 3.9%);
|
||||
--primary: hsl(240 5.9% 10%);
|
||||
--primary-foreground: hsl(0 0% 98%);
|
||||
--secondary: hsl(240 4.8% 95.9%);
|
||||
--secondary-foreground: hsl(240 5.9% 10%);
|
||||
--muted: hsl(240 4.8% 95.9%);
|
||||
--muted-foreground: hsl(240 3.8% 46.1%);
|
||||
--accent: hsl(240 4.8% 95.9%);
|
||||
--accent-foreground: hsl(240 5.9% 10%);
|
||||
--destructive: hsl(0 84.2% 60.2%);
|
||||
--destructive-foreground: hsl(0 0% 98%);
|
||||
--border: hsl(240 5.9% 90%);
|
||||
--input: hsl(240 5.9% 90%);
|
||||
--ring: hsl(240 10% 3.9%);
|
||||
--chart-1: hsl(12 76% 61%);
|
||||
--chart-2: hsl(173 58% 39%);
|
||||
--chart-3: hsl(197 37% 24%);
|
||||
--chart-4: hsl(43 74% 66%);
|
||||
--chart-5: hsl(27 87% 67%);
|
||||
--radius: 0.6rem;
|
||||
--sidebar-background: hsl(0 0% 98%);
|
||||
--sidebar-foreground: hsl(240 5.3% 26.1%);
|
||||
--sidebar-primary: hsl(240 5.9% 10%);
|
||||
--sidebar-primary-foreground: hsl(0 0% 98%);
|
||||
--sidebar-accent: hsl(240 4.8% 95.9%);
|
||||
--sidebar-accent-foreground: hsl(240 5.9% 10%);
|
||||
--sidebar-border: hsl(220 13% 91%);
|
||||
--sidebar-ring: hsl(217.2 91.2% 59.8%);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: hsl(240 10% 3.9%);
|
||||
--foreground: hsl(0 0% 98%);
|
||||
--card: hsl(240 10% 3.9%);
|
||||
--card-foreground: hsl(0 0% 98%);
|
||||
--popover: hsl(240 10% 3.9%);
|
||||
--popover-foreground: hsl(0 0% 98%);
|
||||
--primary: hsl(0 0% 98%);
|
||||
--primary-foreground: hsl(240 5.9% 10%);
|
||||
--secondary: hsl(240 3.7% 15.9%);
|
||||
--secondary-foreground: hsl(0 0% 98%);
|
||||
--muted: hsl(240 3.7% 15.9%);
|
||||
--muted-foreground: hsl(240 5% 64.9%);
|
||||
--accent: hsl(240 3.7% 15.9%);
|
||||
--accent-foreground: hsl(0 0% 98%);
|
||||
--destructive: hsl(0 62.8% 30.6%);
|
||||
--destructive-foreground: hsl(0 0% 98%);
|
||||
--border: hsl(240 3.7% 15.9%);
|
||||
--input: hsl(240 3.7% 15.9%);
|
||||
--ring: hsl(240 4.9% 83.9%);
|
||||
--chart-1: hsl(220 70% 50%);
|
||||
--chart-2: hsl(160 60% 45%);
|
||||
--chart-3: hsl(30 80% 55%);
|
||||
--chart-4: hsl(280 65% 60%);
|
||||
--chart-5: hsl(340 75% 55%);
|
||||
--sidebar-background: hsl(240 5.9% 10%);
|
||||
--sidebar-foreground: hsl(240 4.8% 95.9%);
|
||||
--sidebar-primary: hsl(224.3 76.3% 48%);
|
||||
--sidebar-primary-foreground: hsl(0 0% 100%);
|
||||
--sidebar-accent: hsl(240 3.7% 15.9%);
|
||||
--sidebar-accent-foreground: hsl(240 4.8% 95.9%);
|
||||
--sidebar-border: hsl(240 3.7% 15.9%);
|
||||
--sidebar-ring: hsl(217.2 91.2% 59.8%);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar-background);
|
||||
--animate-accordion-down: accordion-down 0.2s ease-out;
|
||||
--animate-accordion-up: accordion-up 0.2s ease-out;
|
||||
|
||||
@keyframes accordion-down {
|
||||
from {
|
||||
height: 0;
|
||||
}
|
||||
to {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes accordion-up {
|
||||
from {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
to {
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
|
||||
// Bundler mode
|
||||
"moduleResolution": "bundler",
|
||||
"module": "Preserve",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Protocol
|
||||
|
||||
This directory contains stable contracts shared by the frontend and backend.
|
||||
|
||||
Current scope:
|
||||
|
||||
- Xray runtime assets
|
||||
- geoip/geosite assets
|
||||
- download/update task state
|
||||
|
||||
The backend implements these contracts. The frontend consumes generated clients and types from these contracts.
|
||||
@@ -0,0 +1,98 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: pyxray assets API
|
||||
version: 0.1.0
|
||||
description: Contract for Xray runtime asset detection, download, and update.
|
||||
servers:
|
||||
- url: /api/v1
|
||||
paths:
|
||||
/assets/config:
|
||||
get:
|
||||
operationId: getAssetsConfig
|
||||
summary: Read core.toml content
|
||||
responses:
|
||||
"200":
|
||||
description: Core configuration as TOML text.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "../schemas/assets.schema.json#/$defs/CoreConfigText"
|
||||
/assets/status:
|
||||
get:
|
||||
operationId: getAssetsStatus
|
||||
summary: Inspect local Xray and geo assets
|
||||
responses:
|
||||
"200":
|
||||
description: Local asset status.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "../schemas/assets.schema.json#/$defs/AssetsStatus"
|
||||
/assets/update:
|
||||
post:
|
||||
operationId: updateAssets
|
||||
summary: Update assets when needed
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "../schemas/assets.schema.json#/$defs/AssetsUpdateRequest"
|
||||
responses:
|
||||
"202":
|
||||
description: Download task accepted.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "../schemas/assets.schema.json#/$defs/AssetsTaskStartResponse"
|
||||
"409":
|
||||
description: Another download task is already running.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "../schemas/assets.schema.json#/$defs/ErrorResponse"
|
||||
/assets/download:
|
||||
post:
|
||||
operationId: downloadAssets
|
||||
summary: Download selected assets
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "../schemas/assets.schema.json#/$defs/AssetsDownloadRequest"
|
||||
responses:
|
||||
"202":
|
||||
description: Download task accepted.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "../schemas/assets.schema.json#/$defs/AssetsTaskStartResponse"
|
||||
"409":
|
||||
description: Another download task is already running.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "../schemas/assets.schema.json#/$defs/ErrorResponse"
|
||||
/assets/download/task:
|
||||
get:
|
||||
operationId: getAssetsDownloadTask
|
||||
summary: Read current download task status
|
||||
responses:
|
||||
"200":
|
||||
description: Current download task status.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "../schemas/assets.schema.json#/$defs/DownloadTaskStatus"
|
||||
/assets/download/cancel:
|
||||
post:
|
||||
operationId: cancelAssetsDownload
|
||||
summary: Request cancellation for the current download task
|
||||
responses:
|
||||
"200":
|
||||
description: Current download task status after cancellation was requested.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "../schemas/assets.schema.json#/$defs/DownloadTaskStatus"
|
||||
@@ -0,0 +1,130 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://pyxray.local/schemas/assets.schema.json",
|
||||
"title": "pyxray assets schema",
|
||||
"$defs": {
|
||||
"FileStatus": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["exists", "path", "size"],
|
||||
"properties": {
|
||||
"exists": { "type": "boolean" },
|
||||
"path": { "type": "string" },
|
||||
"size": {
|
||||
"type": ["integer", "null"],
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"AssetsStatus": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["installed", "executable_path", "version", "healthy", "geoip", "geosite"],
|
||||
"properties": {
|
||||
"installed": { "type": "boolean" },
|
||||
"executable_path": { "type": "string" },
|
||||
"version": { "type": "string" },
|
||||
"healthy": { "type": "boolean" },
|
||||
"geoip": { "$ref": "#/$defs/FileStatus" },
|
||||
"geosite": { "$ref": "#/$defs/FileStatus" }
|
||||
}
|
||||
},
|
||||
"DownloadKind": {
|
||||
"type": "string",
|
||||
"enum": ["xray", "geo", "geoip", "geosite"]
|
||||
},
|
||||
"DownloadState": {
|
||||
"type": "string",
|
||||
"enum": ["idle", "running", "completed", "canceled", "failed"]
|
||||
},
|
||||
"DownloadTaskStatus": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"state",
|
||||
"kind",
|
||||
"url",
|
||||
"target",
|
||||
"total_bytes",
|
||||
"downloaded_bytes",
|
||||
"progress",
|
||||
"error"
|
||||
],
|
||||
"properties": {
|
||||
"state": { "$ref": "#/$defs/DownloadState" },
|
||||
"kind": {
|
||||
"anyOf": [
|
||||
{ "$ref": "#/$defs/DownloadKind" },
|
||||
{ "type": "null" }
|
||||
]
|
||||
},
|
||||
"url": { "type": "string" },
|
||||
"target": { "type": "string" },
|
||||
"total_bytes": {
|
||||
"type": ["integer", "null"],
|
||||
"minimum": 0
|
||||
},
|
||||
"downloaded_bytes": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"progress": {
|
||||
"type": ["number", "null"],
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"error": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"CoreConfigText": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["path", "content"],
|
||||
"properties": {
|
||||
"path": { "type": "string" },
|
||||
"content": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"AssetsDownloadRequest": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"xray_url": { "type": "string", "default": "" },
|
||||
"geo_url": { "type": "string", "default": "" },
|
||||
"geoip_url": { "type": "string", "default": "" },
|
||||
"geosite_url": { "type": "string", "default": "" },
|
||||
"force": { "type": "boolean", "default": false },
|
||||
"proxy_url": { "type": "string", "default": "" }
|
||||
}
|
||||
},
|
||||
"AssetsUpdateRequest": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"force": { "type": "boolean", "default": false },
|
||||
"proxy_url": { "type": "string", "default": "" }
|
||||
}
|
||||
},
|
||||
"AssetsTaskStartResponse": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["task_id", "state", "items"],
|
||||
"properties": {
|
||||
"task_id": { "type": "string" },
|
||||
"state": { "$ref": "#/$defs/DownloadState" },
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/DownloadKind" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"ErrorResponse": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["error"],
|
||||
"properties": {
|
||||
"error": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
[project]
|
||||
name = "pyxray"
|
||||
version = "1.0.0"
|
||||
description = "A lightweight Linux xray control plane."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
"flask>=3.1.2",
|
||||
"tomlkit>=0.13.3",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
pyxray = "pyxray.cli:main"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=8.4.2",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["uv_build>=0.9.18,<0.10.0"]
|
||||
build-backend = "uv_build"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["."]
|
||||
|
||||
[tool.uv.build-backend]
|
||||
module-name = "pyxray"
|
||||
module-root = ""
|
||||
@@ -1,31 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
|
||||
from pyxray.web.server import run_web
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> None:
|
||||
"""pyxray 命令行入口。"""
|
||||
|
||||
parser = argparse.ArgumentParser(prog="pyxray")
|
||||
subparsers = parser.add_subparsers(dest="command")
|
||||
_add_web_parser(subparsers)
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
if args.command in (None, "web"):
|
||||
run_web(args.host, args.port, args.xray_dir)
|
||||
|
||||
|
||||
def _add_web_parser(subparsers: argparse._SubParsersAction) -> None:
|
||||
"""注册 Web 服务启动参数。"""
|
||||
|
||||
parser = subparsers.add_parser("web", help="启动 Web 控制台")
|
||||
parser.add_argument("--host", default="0.0.0.0", help="监听地址,默认 0.0.0.0")
|
||||
parser.add_argument("--port", default=8000, type=int, help="监听端口,默认 8000")
|
||||
parser.add_argument("--xray-dir", default="data/xray", help="Xray 资源目录,默认 data/xray")
|
||||
parser.set_defaults(command="web")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1 +0,0 @@
|
||||
"""Small reusable building blocks for pyxray."""
|
||||
@@ -1,21 +0,0 @@
|
||||
from pyxray.libs.nodes.errors import InvalidNodeLinkError, NodeLinkError, UnsupportedNodeLinkError
|
||||
from pyxray.libs.nodes.importer import ImportNodeResult, import_node_links
|
||||
from pyxray.libs.nodes.manager import AddNodeResult, NodeManager
|
||||
from pyxray.libs.nodes.model import Node, ParsedNode
|
||||
from pyxray.libs.nodes.parser import parse_node_link
|
||||
from pyxray.libs.nodes.store import NodeStore, NodeStoreData
|
||||
|
||||
__all__ = [
|
||||
"AddNodeResult",
|
||||
"ImportNodeResult",
|
||||
"InvalidNodeLinkError",
|
||||
"Node",
|
||||
"NodeLinkError",
|
||||
"NodeManager",
|
||||
"NodeStore",
|
||||
"NodeStoreData",
|
||||
"ParsedNode",
|
||||
"UnsupportedNodeLinkError",
|
||||
"import_node_links",
|
||||
"parse_node_link",
|
||||
]
|
||||
@@ -1,27 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from urllib.parse import unquote
|
||||
|
||||
|
||||
def first_value(params: dict[str, list[str]], key: str, default: str = "") -> str:
|
||||
"""从 query 参数里取第一个值。"""
|
||||
values = params.get(key)
|
||||
if not values:
|
||||
return default
|
||||
return values[0]
|
||||
|
||||
|
||||
def decode_base64_text(value: str) -> str:
|
||||
"""兼容 URL-safe 和标准 Base64 的文本解码。"""
|
||||
padded = value + "=" * (-len(value) % 4)
|
||||
try:
|
||||
return base64.urlsafe_b64decode(padded.encode()).decode()
|
||||
except Exception:
|
||||
return base64.b64decode(padded.encode()).decode()
|
||||
|
||||
|
||||
def display_name(fragment: str, fallback: str) -> str:
|
||||
"""从链接 fragment 取节点名;没有名称时使用服务器地址。"""
|
||||
name = unquote(fragment).strip()
|
||||
return name or fallback
|
||||
@@ -1,13 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class NodeLinkError(Exception):
|
||||
"""节点链接处理错误基类。"""
|
||||
|
||||
|
||||
class UnsupportedNodeLinkError(NodeLinkError):
|
||||
"""链接协议当前不支持。"""
|
||||
|
||||
|
||||
class InvalidNodeLinkError(NodeLinkError):
|
||||
"""链接协议支持,但内容格式无效。"""
|
||||
@@ -1,43 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from pyxray.libs.nodes.errors import NodeLinkError
|
||||
from pyxray.libs.nodes.model import Node
|
||||
from pyxray.libs.nodes.parser import parse_node_link
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ImportNodeResult:
|
||||
"""批量导入单条链接的处理结果。"""
|
||||
|
||||
link: str
|
||||
node: Node | None = None
|
||||
error: str | None = None
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
"""当前链接是否成功解析。"""
|
||||
return self.node is not None
|
||||
|
||||
|
||||
def import_node_links(text: str) -> list[ImportNodeResult]:
|
||||
"""从多行文本里解析节点链接,不处理订阅分组和远程拉取。"""
|
||||
results: list[ImportNodeResult] = []
|
||||
for link in extract_node_links(text):
|
||||
try:
|
||||
results.append(ImportNodeResult(link=link, node=parse_node_link(link)))
|
||||
except NodeLinkError as exc:
|
||||
results.append(ImportNodeResult(link=link, error=str(exc)))
|
||||
return results
|
||||
|
||||
|
||||
def extract_node_links(text: str) -> list[str]:
|
||||
"""从用户输入中提取候选节点链接。"""
|
||||
links: list[str] = []
|
||||
for line in text.replace("\r\n", "\n").split("\n"):
|
||||
clean = line.strip()
|
||||
if not clean or clean.startswith("#"):
|
||||
continue
|
||||
links.append(clean)
|
||||
return links
|
||||
@@ -1,110 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from pyxray.libs.nodes.importer import ImportNodeResult, import_node_links
|
||||
from pyxray.libs.nodes.model import Node, utc_now
|
||||
from pyxray.libs.nodes.parser import parse_node_link
|
||||
from pyxray.libs.nodes.store import NodeStore, NodeStoreData
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AddNodeResult:
|
||||
"""添加节点后的结果。"""
|
||||
|
||||
node: Node
|
||||
created: bool
|
||||
|
||||
|
||||
class NodeManager:
|
||||
"""管理节点列表和当前选中节点。"""
|
||||
|
||||
def __init__(self, store: NodeStore) -> None:
|
||||
self.store = store
|
||||
|
||||
def list_nodes(self) -> list[Node]:
|
||||
"""返回当前保存的全部节点。"""
|
||||
return self.store.load().nodes
|
||||
|
||||
def get_node(self, node_id: str) -> Node | None:
|
||||
"""按节点 ID 查找节点。"""
|
||||
for node in self.store.load().nodes:
|
||||
if node.id == node_id:
|
||||
return node
|
||||
return None
|
||||
|
||||
def add_link(self, link: str) -> AddNodeResult:
|
||||
"""解析并添加单条节点链接。"""
|
||||
return self.add_node(parse_node_link(link))
|
||||
|
||||
def add_node(self, node: Node) -> AddNodeResult:
|
||||
"""添加或更新节点;ID 已存在时保留原创建时间。"""
|
||||
data = self.store.load()
|
||||
nodes: list[Node] = []
|
||||
created = True
|
||||
now = utc_now()
|
||||
|
||||
for current in data.nodes:
|
||||
if current.id != node.id:
|
||||
nodes.append(current)
|
||||
continue
|
||||
node.created_at = current.created_at
|
||||
node.updated_at = now
|
||||
nodes.append(node)
|
||||
created = False
|
||||
|
||||
if created:
|
||||
node.created_at = now
|
||||
node.updated_at = now
|
||||
nodes.append(node)
|
||||
|
||||
self.store.save(NodeStoreData(nodes=nodes, selected_id=data.selected_id))
|
||||
return AddNodeResult(node=node, created=created)
|
||||
|
||||
def import_links(self, text: str) -> list[ImportNodeResult]:
|
||||
"""批量导入节点链接;解析失败的链接会保留错误结果。"""
|
||||
results = import_node_links(text)
|
||||
for result in results:
|
||||
if result.node is not None:
|
||||
self.add_node(result.node)
|
||||
return results
|
||||
|
||||
def remove_node(self, node_id: str) -> bool:
|
||||
"""删除节点;如果删除的是当前选中节点,则同时清空选择。"""
|
||||
data = self.store.load()
|
||||
nodes = [node for node in data.nodes if node.id != node_id]
|
||||
removed = len(nodes) != len(data.nodes)
|
||||
if not removed:
|
||||
return False
|
||||
|
||||
selected_id = "" if data.selected_id == node_id else data.selected_id
|
||||
self.store.save(NodeStoreData(nodes=nodes, selected_id=selected_id))
|
||||
return True
|
||||
|
||||
def select_node(self, node_id: str) -> Node:
|
||||
"""选择一个已存在节点。"""
|
||||
data = self.store.load()
|
||||
for node in data.nodes:
|
||||
if node.id == node_id:
|
||||
self.store.save(NodeStoreData(nodes=data.nodes, selected_id=node_id))
|
||||
return node
|
||||
raise ValueError(f"node does not exist: {node_id}")
|
||||
|
||||
def clear_selection(self) -> None:
|
||||
"""清空当前选中节点。"""
|
||||
data = self.store.load()
|
||||
self.store.save(NodeStoreData(nodes=data.nodes, selected_id=""))
|
||||
|
||||
def get_selected_node(self) -> Node | None:
|
||||
"""返回当前选中节点;没有选择或节点已不存在时返回 None。"""
|
||||
data = self.store.load()
|
||||
if not data.selected_id:
|
||||
return None
|
||||
for node in data.nodes:
|
||||
if node.id == data.selected_id:
|
||||
return node
|
||||
return None
|
||||
|
||||
def selected_id(self) -> str:
|
||||
"""返回当前选中节点 ID。"""
|
||||
return self.store.load().selected_id
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user