8 Commits

156 changed files with 4514 additions and 9857 deletions
-28
View File
@@ -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
-84
View File
@@ -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
View File
@@ -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
View File
@@ -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"]
+9 -130
View File
@@ -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.
+19
View File
@@ -0,0 +1,19 @@
data-test-*/
data/
.venv/
__pycache__/
*.py[cod]
.pytest_cache/
.mypy_cache/
.ruff_cache/
*.log
.env
.env.*
.idea/
.vscode/
*.swp
*.swo
+5
View File
@@ -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.
+43
View File
@@ -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"]
+5
View File
@@ -0,0 +1,5 @@
"""pyxray backend runtime."""
__all__ = ["__version__"]
__version__ = "0.1.0"
+1
View File
@@ -0,0 +1 @@
"""HTTP API adapters."""
+15
View File
@@ -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
+78
View File
@@ -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()
+13
View File
@@ -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,
)
+1
View File
@@ -0,0 +1 @@
"""Asset management service layer."""
+34
View File
@@ -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
+124
View File
@@ -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)
+10
View File
@@ -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)
+69
View File
@@ -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)
+22
View File
@@ -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."),
]
+79
View File
@@ -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)
+45
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""Shared backend utilities."""
+29
View File
@@ -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()
+14
View File
@@ -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)
+52
View File
@@ -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)
+69
View File
@@ -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}"
+23
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
"""Xray runtime management."""
+48
View File
@@ -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))
+56
View File
@@ -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"),
)
+122
View File
@@ -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)
+153
View File
@@ -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,
)
)
+68
View 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)
+28
View File
@@ -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)
+96
View File
@@ -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())
+79
View File
@@ -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()
+14
View File
@@ -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()
+39
View File
@@ -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
+35
View File
@@ -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")
+19
View File
@@ -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
+14
View File
@@ -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"}
+134
View File
@@ -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)
+466
View File
@@ -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" },
]
-16
View File
@@ -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
-53
View File
@@ -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) | Mixed/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` | 规则 Mixed 代理入口。 |
| `routing.mode` | `whitelist` | 国内/私有直连,其它代理。 |
| `transparent.mode` | `close` | 默认不启用透明代理。 |
| `transparent.type` | `redirect` | 推荐先用 redirect。 |
| `dns.query_strategy` | `UseIPv4` | 默认优先 IPv4。 |
| `dns.local_dns_listen` | `true` | redirect 透明代理下生成本地 DNS 入站。 |
-32
View File
@@ -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 中下载。 |
-61
View File
@@ -1,61 +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 时修改。 |
| `transparent.output_bypass_rules` | 显示 | `""` | 每行一条 `tcp/udp/all 目标[:端口]` | 在 transparent 系统规则的本机 `OUTPUT` 链前置 RETURN,避免宿主机进程被透明代理截获。 | easytier、其它 host network 服务需要直连固定 peer 时修改。 |
| `ss_backend` | 隐藏 | `""` | 字符串 | 预留字段,当前不影响 Xray JSON。 | 当前不用改。 |
| `trojan_backend` | 隐藏 | `""` | 字符串 | 预留字段,当前不影响 Xray JSON。 | 当前不用改。 |
## transparent
核心卡片里的 `transparent` 输入框对应 `[transparent].output_bypass_rules`
格式:
```text
tcp 117.72.47.28:33010
all 192.168.0.0/24
udp 198.51.100.10:3478
```
规则含义:
| 写法 | 作用 |
| --- | --- |
| `tcp 117.72.47.28:33010` | 本机 TCP 访问该 IP 和端口时直连,不进入 transparent。 |
| `all 192.168.0.0/24` | 本机访问该网段时直连;redirect 下只生成 TCPtproxy 下生成 TCP 和 UDP。 |
| `udp 198.51.100.10:3478` | tproxy 下本机 UDP 访问该 IP 和端口时直连;redirect 下忽略 UDP。 |
典型场景:
```text
tcp 117.72.47.28:33010
```
用于避免 easytier 这类 `network_mode: host` 服务的 peer 连接被 `nat OUTPUT -> TP_OUT -> REDIRECT` 截获。
生成顺序:
```sh
iptables -t nat -A TP_OUT -p tcp -d 117.72.47.28 --dport 33010 -j RETURN
iptables -t nat -A TP_OUT -j TP_RULE
```
该设置只影响宿主机本机 `OUTPUT` 流量,不影响 Docker 容器透明代理的 `PREROUTING` 流量。
## 生成影响
| 条件 | 生成结果 |
| --- | --- |
| `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`。 |
| `transparent.output_bypass_rules` 非空 | 在 `TP_OUT` 跳转 `TP_RULE` 前生成 OUTPUT 绕过 RETURN 规则。 |
-46
View File
@@ -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,避免代理节点域名解析走错。 |
-78
View File
@@ -1,78 +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 入站兼容字段。当前 UI 保存时写 `0`。 | 通常不改。 |
| `rule_http_port` | 显示为 Mixed 端口 | `20172` | `0-65535` | 规则 mixed 入站,同一个端口同时支持 HTTP 和 SOCKS,流量按 `[routing]` 规则分流。 | 浏览器、系统代理、CLI 工具显式代理时使用。 |
| `auth_user` | 显示 | `""` | 字符串 | mixed/SOCKS 入站认证用户名。 | 需要给局域网开放代理但不想裸奔时设置。 |
| `auth_password` | 显示 | `""` | 字符串 | mixed/SOCKS 入站认证密码。 | 与 `auth_user` 一起设置;两者都为空时使用 `noauth`。 |
| `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-mixed` | `rule_http_port` | 同一端口支持 HTTP 和 SOCKS,进入 `[routing]` 模式规则。 |
| `vmess` | `vmess_port` | 额外 VMess 入站。 |
| `api-in` | `api.port` | 路由到 `api-out`。 |
## Mixed 入站
`rule-mixed` 生成示例:
```json
{
"tag": "rule-mixed",
"listen": "0.0.0.0",
"port": 20172,
"protocol": "mixed",
"settings": {
"auth": "noauth",
"udp": true,
"allowTransparent": false
}
}
```
如果填写用户名和密码:
```json
"settings": {
"auth": "password",
"udp": true,
"allowTransparent": false,
"accounts": [
{"user": "alice", "pass": "secret"}
]
}
```
使用方式:
```sh
curl -x http://10.11.11.100:20172 http://google.com/
curl --socks5-hostname 10.11.11.100:20172 https://google.com/
curl -x http://alice:secret@10.11.11.100:20172 http://google.com/
curl --socks5-hostname alice:secret@10.11.11.100:20172 https://google.com/
```
-22
View File
@@ -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` | 订阅更新时连接模式预留。当前无订阅功能。 | 当前不用改。 |
-43
View File
@@ -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` | 命中后的出口。 |
-75
View File
@@ -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` 解析白名单 CIDRtproxy 下 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
View File
@@ -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。
- 不在没有明确需求前添加服务接口。
-74
View File
@@ -1,74 +0,0 @@
# easytier 连接 peer 超时
## 问题原因
`easytier` 使用 `network_mode: host`,它发起的 peer 连接属于宿主机本机流量,会经过 `nat OUTPUT`
`pyxray` 开启 transparent redirect 后,会把宿主机本机 TCP 流量转到 Xray transparent inbound
```sh
iptables -t nat -I OUTPUT -p tcp -j TP_OUT
iptables -t nat -A TP_OUT -j TP_RULE
iptables -t nat -A TP_RULE -p tcp -j REDIRECT --to-ports 52345
```
因此 easytier 访问 peer 时,连接会被改写:
```mermaid
flowchart LR
E[easytier-core<br/>tcp://117.72.47.28:33010] --> O[nat OUTPUT]
O --> TPO[TP_OUT]
TPO --> TPR[TP_RULE]
TPR --> R[REDIRECT :52345]
R --> X[Xray transparent inbound]
```
结果是 easytier 没有直连到自己的 peer,日志表现为:
```text
connecting to peer dst=tcp://117.72.47.28:33010
connect to peer error ... Timeout
```
## 解决方案
在 UI 的“核心 -> transparent”里添加 OUTPUT 绕过规则,让 easytier peer 连接在进入 `TP_RULE` 前直接 `RETURN`
示例:
```text
tcp 117.72.47.28:33010
```
生成后的关键规则:
```sh
iptables -t nat -A TP_OUT -p tcp -d 117.72.47.28 --dport 33010 -j RETURN
iptables -t nat -A TP_OUT -j TP_RULE
```
修复后的流量路径:
```mermaid
flowchart LR
E[easytier-core<br/>tcp://117.72.47.28:33010] --> O[nat OUTPUT]
O --> TPO[TP_OUT]
TPO --> B{match tcp<br/>117.72.47.28:33010}
B -->|yes| D[RETURN<br/>直连 peer]
B -->|no| TPR[TP_RULE]
TPR --> R[REDIRECT :52345]
```
规则格式:
```text
tcp 117.72.47.28:33010
all 192.168.0.0/24
udp 198.51.100.10:3478
```
说明:
- `redirect` 模式只处理 TCP,因此只生成 TCP 绕过规则。
- `tproxy` 模式支持 TCP 和 UDP。
- 规则只作用于宿主机本机 `OUTPUT`,不改变 Docker 容器透明代理的 `PREROUTING` 行为。
-89
View File
@@ -1,89 +0,0 @@
# VLESS Reality Vision 开启 mux 后连接被关闭
## 问题
在节点使用 `VLESS + REALITY + xtls-rprx-vision` 时,开启 `mux` 后,透明代理和本地 HTTP/SOCKS 代理都会出现请求失败。
现象:
```text
curl google.com
curl: (52) Empty reply from server
```
Xray 日志:
```text
common/mux: dispatching request to tcp:google.com:80
proxy/vless/outbound: tunneling request to tcp:v1.mux.cool:9527
common/mux: failed to read metadata > io: read/write on closed pipe
```
关闭 `mux` 后,同一节点恢复正常:
```text
curl http://google.com/ -> HTTP/1.1 301
curl https://google.com/ -> HTTP/2 301
HTTP/SOCKS inbound 测试 -> 正常
```
## 原因
当前失败不在 transparent/iptables,而在 Xray outbound 层。
`mux` 会把多个 TCP 请求封装进一个 Mux.Cool 连接;官方文档说明它用于减少 TCP 握手延迟,默认关闭,并且不用于提升吞吐。`xtls-rprx-vision` 是 VLESS 的 XTLS flow,官方文档说明它在 `TCP + TLS/REALITY` 下会对 TLS 1.3 数据走底层直拷路径。
两者叠加时,业务连接不再按普通 VLESS 请求直接发送,而是先被封装成 `v1.mux.cool` 子连接:
```mermaid
flowchart LR
A[curl / app] --> I[Xray inbound]
I --> R[routing -> proxy]
R --> M[mux<br/>v1.mux.cool]
M --> V[VLESS<br/>flow=xtls-rprx-vision]
V --> T[REALITY/TCP server]
T --> X[server closes pipe]
```
Xray-core 讨论区有同类案例:配置为 `VLESS + REALITY + TCP + xtls-rprx-vision + mux` 时,请求报 `curl: (52) Empty reply from server`,服务端日志出现 `common/mux``closed pipe` 类错误;去掉 `mux` 后恢复。
因此这里的结论是:该节点组合下 `mux``xtls-rprx-vision` 不兼容或服务端不接受 mux 封装后的请求。
## 解决方案
在 UI 的“核心设置”里关闭 `mux`,保存并重启 Xray。
生成配置中不要出现:
```json
"mux": {
"enabled": true,
"concurrency": 4
}
```
修复后路径:
```mermaid
flowchart LR
A[curl / app] --> I[Xray inbound]
I --> R[routing -> proxy]
R --> V[VLESS<br/>flow=xtls-rprx-vision]
V --> T[REALITY/TCP server]
T --> G[google.com / target]
```
验证命令:
```sh
curl -v http://google.com/
curl -vk https://google.com/
curl -v -x http://127.0.0.1:20172 http://google.com/
curl -v --socks5-hostname 127.0.0.1:20170 https://google.com/
```
参考:
- [Project X: Outbound Proxy (Mux, XUDP)](https://xtls.github.io/en/config/outbound.html)
- [Project X: VLESS (XTLS Vision Seed)](https://xtls.github.io/en/config/inbounds/vless.html)
- [XTLS/Xray-core discussion #5481](https://github.com/XTLS/Xray-core/discussions/5481)
-183
View File
@@ -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` 对应的 mixed 端口,或开启透明代理。 |
| 域名规则没命中 | 流量只有 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 |
+34
View File
@@ -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
+21
View File
@@ -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.
+169
View File
@@ -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`);
+17
View File
@@ -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;
}
+254
View File
@@ -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=="],
}
}
+3
View File
@@ -0,0 +1,3 @@
[serve.static]
plugins = ["bun-plugin-tailwind"]
env = "BUN_PUBLIC_*"
+21
View File
@@ -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"
}
+37
View File
@@ -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"
}
}
+9
View File
@@ -0,0 +1,9 @@
import "./index.css";
import { AssetsPage } from "@/components/assets/AssetsPage";
export function App() {
return <AssetsPage />;
}
export default App;
+41
View File
@@ -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",
});
}
+32
View File
@@ -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;
}
+327
View File
@@ -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>
);
}
+28
View File
@@ -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 : "未知错误";
}
+1
View File
@@ -0,0 +1 @@
export type DownloadTarget = "all" | "xray" | "geoip" | "geosite";
+48
View File
@@ -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 };
+37
View File
@@ -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 };
+141
View File
@@ -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 };
+19
View File
@@ -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 };
+21
View File
@@ -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 };
+150
View File
@@ -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,
};
+26
View File
@@ -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);
}
+19
View File
@@ -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;
}
}
+12
View File
@@ -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>
+32
View File
@@ -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}`);
+6
View File
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+1
View File
@@ -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

+8
View File
@@ -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

+144
View File
@@ -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;
}
}
+20
View File
@@ -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"]
}
+1
View File
@@ -0,0 +1 @@
+11
View File
@@ -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.
+98
View File
@@ -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"
+130
View File
@@ -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" }
}
}
}
}
-30
View File
@@ -1,30 +0,0 @@
[project]
name = "pyxray"
version = "1.0.4"
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 = ""
-10
View File
@@ -1,10 +0,0 @@
"""pyxray package."""
from importlib.metadata import PackageNotFoundError, version
__all__ = ["__version__"]
try:
__version__ = version("pyxray")
except PackageNotFoundError:
__version__ = "0.0.0"
-31
View File
@@ -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
View File
@@ -1 +0,0 @@
"""Small reusable building blocks for pyxray."""
-21
View File
@@ -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",
]
-27
View File
@@ -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

Some files were not shown because too many files have changed in this diff Show More