Initial Flet VPN monorepo skeleton
This commit is contained in:
commit
449ce76751
77 changed files with 4978 additions and 0 deletions
4
.env.example
Normal file
4
.env.example
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
VPNAPP_ADMIN_TOKEN=replace-with-a-long-random-operator-token
|
||||
VPNAPP_SECRET_KEY=replace-with-Fernet-key
|
||||
VPNAPP_BASE_URL=https://sub.example.com
|
||||
VPNAPP_DATABASE=backend/vpnapp.db
|
||||
19
.gitignore
vendored
Normal file
19
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
.venv/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
build/
|
||||
dist/
|
||||
*.apk
|
||||
*.aab
|
||||
*.aar
|
||||
native/android/libs/*
|
||||
!native/android/libs/README.md
|
||||
native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/android/libs/*
|
||||
native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/android/src/main/jniLibs/
|
||||
backend/*.db
|
||||
backend/*.db-*
|
||||
.env
|
||||
.DS_Store
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 SNW
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
82
README.md
Normal file
82
README.md
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
# VPN App
|
||||
|
||||
Private Android/Desktop client built as a small monorepo:
|
||||
|
||||
- `app/` — Flet/Python UI, profiles, settings, runtime state and common business logic.
|
||||
- `native/android/` — Flet service extension and Kotlin service boundary. VPN lifecycle,
|
||||
TUN, foreground service, hotspot and mobile libraries belong here, not in Python.
|
||||
- `backend/` — deliberately small FastAPI + SQLite operator API and device subscription
|
||||
endpoint.
|
||||
- `docs/` — architecture, upstream research, implementation status and device test plan.
|
||||
|
||||
The current product scope is Android first and desktop proxy-only. iOS is intentionally
|
||||
not in this repository. macOS is not a target of the first build; if added later it will
|
||||
be proxy-only unless a separate native implementation is approved.
|
||||
|
||||
## Current product model
|
||||
|
||||
The app has two top-level tabs:
|
||||
|
||||
- **VPN** — `Основная` (VLESS) and `Усиленная` (AmneziaWG 3.1), both through the
|
||||
`SignBox` adapter contract.
|
||||
- **Белые списки** — FreeTurn, with stream presets `1..4`, CAPTCHA handling and
|
||||
optional stability mode.
|
||||
|
||||
The Python engine manager is real and tested. Desktop `SignBox` proxy mode can launch a
|
||||
configured local executable when one is supplied. Android native lifecycle and command
|
||||
protocol are implemented; FreeTurn is integrated from a pinned local AAR during the
|
||||
build, while SignBox remains `UNSUPPORTED` until the exact signed artifact and API are
|
||||
supplied. Engine AARs are intentionally not checked into git. The UI never treats an
|
||||
unsupported result as a successful connection.
|
||||
|
||||
The exact remaining functional blockers are tracked in
|
||||
[`docs/implementation-status.md`](docs/implementation-status.md); an APK build alone
|
||||
is not evidence that the missing VPN data path works.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
uv run flet run app/main.py
|
||||
uv run pytest
|
||||
```
|
||||
|
||||
Run the backend locally:
|
||||
|
||||
```bash
|
||||
export VPNAPP_ADMIN_TOKEN='replace-this-in-a-secret-shell'
|
||||
uv run uvicorn backend.main:app --reload --port 8080
|
||||
```
|
||||
|
||||
The backend deliberately requires HTTPS in deployment. `VPNAPP_ADMIN_TOKEN` is an
|
||||
operator credential only. Devices use a separate random bearer credential; UUIDs and
|
||||
User-Agent strings are not authentication.
|
||||
|
||||
## Build scripts
|
||||
|
||||
```bash
|
||||
./scripts/fetch-engine-artifacts.sh # optional, requires upstream build tools
|
||||
./scripts/build-android.sh # flet build apk (requires Android SDK/Flutter)
|
||||
./scripts/build-desktop.sh linux # flet build linux
|
||||
./scripts/run-backend.sh # local backend
|
||||
```
|
||||
|
||||
For Android, `./scripts/build-android.sh` builds an arm64 APK with any reviewed artifacts
|
||||
present locally; missing engines report `UNSUPPORTED` at runtime. For a releasable build,
|
||||
place the exact `freeturn.aar` and compatible `libbox.aar` under
|
||||
`native/android/libs/` (the directory is ignored to avoid silently shipping unreviewed
|
||||
native code) and set `REQUIRE_NATIVE_ARTIFACTS=1`.
|
||||
|
||||
## Security posture
|
||||
|
||||
The default proxy bind is localhost. Wi-Fi sharing is explicit, binds to the discovered
|
||||
LocalOnlyHotspot address, and requires authentication by default. Management/debug APIs
|
||||
are disabled by default. Secrets are redacted from logs. Application routing models
|
||||
distinguish `PROXY`, `DIRECT`, and `VOID`; native UID matching and fail-closed `VOID`
|
||||
enforcement are not yet shipped and are never presented as active security.
|
||||
|
||||
## License and upstreams
|
||||
|
||||
This repository's original code is MIT-licensed; see `LICENSE`. Upstream components,
|
||||
versions, URLs and license obligations are recorded in `THIRD_PARTY_NOTICES.md`.
|
||||
Do not copy the GPL Android reference application's UI or source into this project.
|
||||
19
THIRD_PARTY_NOTICES.md
Normal file
19
THIRD_PARTY_NOTICES.md
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
# Third-party notices
|
||||
|
||||
This file is intentionally explicit about components that may be bundled. Native
|
||||
artifacts are not committed to this repository; the exact version and SHA-256 must be
|
||||
recorded here before a release artifact is built.
|
||||
|
||||
| Component | Use | Version/commit | License | Source |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Flet | Python UI/runtime packaging | pinned by `uv.lock` | Apache-2.0 | https://github.com/flet-dev/flet |
|
||||
| FreeTurn mobile API | Android FreeTurn allowlist/whitelist transport | `samosvalishe/free-turn-proxy` commit `065385e656085a06bf8079958bf1b563901db64f`; local AAR SHA-256 `112207f15bd5c93500be13e249f884dec0768d0f542599b3e24f641837483a6e` | GPL-3.0 | https://github.com/samosvalishe/free-turn-proxy |
|
||||
| FreeTurn Android reference | lifecycle/API reference only; not a source dependency | inspected only | GPL-3.0 | https://github.com/samosvalishe/turn-proxy-android |
|
||||
| sign-box/libbox or private SignBox fork | VPN/proxy engine artifact, if supplied | **must be pinned before release** | check supplied artifact/source | https://github.com/SagerNet/sing-box |
|
||||
| XTLS/libXray | optional future VLESS mobile binding; not currently bundled | **not bundled** | MPL-2.0 | https://github.com/XTLS/libXray |
|
||||
| FastAPI / Uvicorn / Pydantic / qrcode | Python app/backend dependencies | pinned by `uv.lock` | respective upstream licenses | PyPI |
|
||||
|
||||
The Android reference application is not copied. Its GPL license does not automatically
|
||||
apply to an independent implementation that only uses documented APIs, but distributing
|
||||
the FreeTurn GPL mobile library still requires complying with its license and providing
|
||||
corresponding notices/source obligations as applicable.
|
||||
1
app/__init__.py
Normal file
1
app/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Flet application package."""
|
||||
1
app/core/__init__.py
Normal file
1
app/core/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Engine abstraction and adapters."""
|
||||
6
app/core/errors.py
Normal file
6
app/core/errors.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
class EngineError(RuntimeError):
|
||||
"""An engine failed to start, stop, or report state."""
|
||||
|
||||
|
||||
class UnsupportedEngineError(EngineError):
|
||||
"""The requested capability is not present in the current artifact."""
|
||||
23
app/core/factory.py
Normal file
23
app/core/factory.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
from app.core.freeturn import FreeTurnBackend
|
||||
from app.core.manager import EngineManager
|
||||
from app.core.native import NativeEngineBackend
|
||||
from app.core.registry import BackendRegistry
|
||||
from app.core.signbox import SignBoxBackend
|
||||
from app.models import EngineKind
|
||||
|
||||
|
||||
def build_engine_manager(native_bridge=None) -> EngineManager:
|
||||
registry = BackendRegistry()
|
||||
if native_bridge is None:
|
||||
registry.register(EngineKind.SIGNBOX, SignBoxBackend)
|
||||
registry.register(EngineKind.FREETURN, FreeTurnBackend)
|
||||
else:
|
||||
registry.register(
|
||||
EngineKind.SIGNBOX,
|
||||
lambda: NativeEngineBackend(EngineKind.SIGNBOX, native_bridge),
|
||||
)
|
||||
registry.register(
|
||||
EngineKind.FREETURN,
|
||||
lambda: NativeEngineBackend(EngineKind.FREETURN, native_bridge),
|
||||
)
|
||||
return EngineManager(registry)
|
||||
64
app/core/freeturn.py
Normal file
64
app/core/freeturn.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from app.core.errors import UnsupportedEngineError
|
||||
from app.core.interface import EngineBackend
|
||||
from app.models import (
|
||||
CapabilitySet,
|
||||
EngineKind,
|
||||
EngineState,
|
||||
RuntimeRequest,
|
||||
RuntimeSnapshot,
|
||||
RuntimeStats,
|
||||
)
|
||||
|
||||
|
||||
class FreeTurnBackend(EngineBackend):
|
||||
"""FreeTurn adapter contract.
|
||||
|
||||
The actual Android calls (`Mobile.Start`, `StartTunnel`, `SetProtect`, events,
|
||||
CAPTCHA) are owned by the native service. Desktop support is intentionally not
|
||||
claimed until a desktop build of the core is selected.
|
||||
"""
|
||||
|
||||
name = "freeturn"
|
||||
capabilities = CapabilitySet(
|
||||
supports_vpn=False,
|
||||
supports_proxy=True,
|
||||
supports_socks=False,
|
||||
supports_http=False,
|
||||
supports_udp=True,
|
||||
supports_mobile=True,
|
||||
supports_desktop=False,
|
||||
supports_stats=True,
|
||||
supports_hot_reload=False,
|
||||
supports_both=False,
|
||||
)
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._state = RuntimeSnapshot(engine=EngineKind.FREETURN)
|
||||
|
||||
async def start(self, request: RuntimeRequest) -> RuntimeSnapshot:
|
||||
self._state = RuntimeSnapshot(
|
||||
state=EngineState.UNSUPPORTED,
|
||||
engine=EngineKind.FREETURN,
|
||||
mode=request.mode,
|
||||
profile_id=request.profile_id,
|
||||
message="FreeTurn is available only through the Android native mobile binding",
|
||||
)
|
||||
raise UnsupportedEngineError(
|
||||
self._state.message or "FreeTurn is unsupported on this platform"
|
||||
)
|
||||
|
||||
async def stop(self) -> RuntimeSnapshot:
|
||||
self._state = self._state.model_copy(update={"state": EngineState.STOPPED})
|
||||
return self._state
|
||||
|
||||
async def restart(self, request: RuntimeRequest) -> RuntimeSnapshot:
|
||||
await self.stop()
|
||||
return await self.start(request)
|
||||
|
||||
async def get_state(self) -> RuntimeSnapshot:
|
||||
return self._state
|
||||
|
||||
async def get_stats(self) -> RuntimeStats:
|
||||
return self._state.stats
|
||||
31
app/core/interface.py
Normal file
31
app/core/interface.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from app.models import CapabilitySet, RuntimeRequest, RuntimeSnapshot, RuntimeStats
|
||||
|
||||
|
||||
class EngineBackend(ABC):
|
||||
"""Platform-independent lifecycle contract.
|
||||
|
||||
Platform adapters may implement this through a desktop process or through the
|
||||
Android native bridge. Python never owns Android VPN process lifetime.
|
||||
"""
|
||||
|
||||
name: str
|
||||
capabilities: CapabilitySet
|
||||
|
||||
@abstractmethod
|
||||
async def start(self, request: RuntimeRequest) -> RuntimeSnapshot: ...
|
||||
|
||||
@abstractmethod
|
||||
async def stop(self) -> RuntimeSnapshot: ...
|
||||
|
||||
@abstractmethod
|
||||
async def restart(self, request: RuntimeRequest) -> RuntimeSnapshot: ...
|
||||
|
||||
@abstractmethod
|
||||
async def get_state(self) -> RuntimeSnapshot: ...
|
||||
|
||||
@abstractmethod
|
||||
async def get_stats(self) -> RuntimeStats: ...
|
||||
106
app/core/manager.py
Normal file
106
app/core/manager.py
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from app.core.errors import EngineError
|
||||
from app.core.interface import EngineBackend
|
||||
from app.core.registry import BackendRegistry
|
||||
from app.models import (
|
||||
CapabilitySet,
|
||||
EngineKind,
|
||||
EngineState,
|
||||
RuntimeRequest,
|
||||
RuntimeSnapshot,
|
||||
RuntimeStats,
|
||||
)
|
||||
|
||||
|
||||
class EngineManager:
|
||||
"""Serializes runtime operations and prevents FreeTurn/VPN conflicts."""
|
||||
|
||||
def __init__(self, registry: BackendRegistry) -> None:
|
||||
self.registry = registry
|
||||
self._active_kind: EngineKind | None = None
|
||||
self._backend: EngineBackend | None = None
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
@property
|
||||
def active_kind(self) -> EngineKind | None:
|
||||
return self._active_kind
|
||||
|
||||
def capabilities(self, kind: EngineKind) -> CapabilitySet:
|
||||
if self._backend is not None and self._active_kind is kind:
|
||||
return self._backend.capabilities
|
||||
return self.registry.create(kind).capabilities
|
||||
|
||||
async def start(self, request: RuntimeRequest) -> RuntimeSnapshot:
|
||||
async with self._lock:
|
||||
if self._active_kind is not None and self._active_kind is not request.engine:
|
||||
raise EngineError("another engine is active; stop it before switching")
|
||||
backend = (
|
||||
self._backend
|
||||
if self._backend is not None and self._active_kind is request.engine
|
||||
else self.registry.create(request.engine)
|
||||
)
|
||||
self._backend = backend
|
||||
if request.mode.value in {"vpn", "both"} and not backend.capabilities.supports_vpn:
|
||||
raise EngineError(f"{request.engine} does not support VPN mode")
|
||||
if request.mode.value in {"proxy", "both"} and not backend.capabilities.supports_proxy:
|
||||
raise EngineError(f"{request.engine} does not support proxy mode")
|
||||
if request.mode.value == "both" and not backend.capabilities.supports_both:
|
||||
raise EngineError(f"{request.engine} does not support Both mode")
|
||||
snapshot = await backend.start(request)
|
||||
if snapshot.state in {EngineState.RUNNING, EngineState.STARTING}:
|
||||
self._active_kind = request.engine
|
||||
return snapshot
|
||||
|
||||
async def stop(self) -> RuntimeSnapshot:
|
||||
async with self._lock:
|
||||
if self._backend is None:
|
||||
return RuntimeSnapshot()
|
||||
snapshot = await self._backend.stop()
|
||||
if snapshot.state in {EngineState.STOPPED, EngineState.ERROR, EngineState.UNSUPPORTED}:
|
||||
self._active_kind = None
|
||||
self._backend = None
|
||||
return snapshot
|
||||
|
||||
async def restart(self, request: RuntimeRequest) -> RuntimeSnapshot:
|
||||
async with self._lock:
|
||||
if self._active_kind is not None and self._active_kind is not request.engine:
|
||||
raise EngineError("another engine is active; stop it before switching")
|
||||
backend = (
|
||||
self._backend
|
||||
if self._backend is not None and self._active_kind is request.engine
|
||||
else self.registry.create(request.engine)
|
||||
)
|
||||
self._backend = backend
|
||||
snapshot = await backend.restart(request)
|
||||
if snapshot.state in {EngineState.RUNNING, EngineState.STARTING}:
|
||||
self._active_kind = request.engine
|
||||
return snapshot
|
||||
|
||||
async def get_state(self) -> RuntimeSnapshot:
|
||||
async with self._lock:
|
||||
if self._backend is None:
|
||||
# Native services can outlive the Flet process. Probe registered
|
||||
# adapters so a newly opened UI can discover the authoritative
|
||||
# runtime instead of interpreting missing Python state as STOPPED.
|
||||
for kind in self.registry.kinds():
|
||||
candidate = self.registry.create(kind)
|
||||
snapshot = await candidate.get_state()
|
||||
if snapshot.engine is kind and snapshot.state in {
|
||||
EngineState.STARTING,
|
||||
EngineState.RUNNING,
|
||||
EngineState.STOPPING,
|
||||
}:
|
||||
self._backend = candidate
|
||||
self._active_kind = kind
|
||||
return snapshot
|
||||
return RuntimeSnapshot()
|
||||
return await self._backend.get_state()
|
||||
|
||||
async def get_stats(self) -> RuntimeStats:
|
||||
async with self._lock:
|
||||
if self._backend is None:
|
||||
return RuntimeStats()
|
||||
return await self._backend.get_stats()
|
||||
208
app/core/native.py
Normal file
208
app/core/native.py
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.core.errors import EngineError, UnsupportedEngineError
|
||||
from app.core.interface import EngineBackend
|
||||
from app.models import (
|
||||
CapabilitySet,
|
||||
EngineKind,
|
||||
EngineState,
|
||||
RunMode,
|
||||
RuntimeRequest,
|
||||
RuntimeSnapshot,
|
||||
RuntimeStats,
|
||||
)
|
||||
|
||||
|
||||
class NativeEngineBackend(EngineBackend):
|
||||
"""Android implementation backed by the Flet service extension.
|
||||
|
||||
The service returns immediately with ``starting``; native SharedPreferences is
|
||||
authoritative and is polled by ``get_state``. This class contains no Android
|
||||
lifecycle code and is therefore safe to keep out of desktop builds.
|
||||
"""
|
||||
|
||||
def __init__(self, kind: EngineKind, bridge: Any) -> None:
|
||||
self.kind = kind
|
||||
self.bridge = bridge
|
||||
self.name = kind.value
|
||||
self.capabilities = self._capabilities(kind)
|
||||
self._request: RuntimeRequest | None = None
|
||||
self._state = RuntimeSnapshot(engine=kind)
|
||||
|
||||
@staticmethod
|
||||
def _capabilities(kind: EngineKind) -> CapabilitySet:
|
||||
if kind is EngineKind.FREETURN:
|
||||
return CapabilitySet(
|
||||
supports_proxy=True,
|
||||
supports_socks=False,
|
||||
supports_udp=True,
|
||||
supports_mobile=True,
|
||||
supports_stats=True,
|
||||
)
|
||||
return CapabilitySet(
|
||||
# The exact SignBox AAR/API is a release input. Keeping these false
|
||||
# prevents the UI from advertising a native path that is not bundled.
|
||||
supports_mobile=True,
|
||||
)
|
||||
|
||||
async def start(self, request: RuntimeRequest) -> RuntimeSnapshot:
|
||||
payload = request.model_dump(mode="json")
|
||||
existing = await self.bridge.get_runtime_state()
|
||||
existing_state = self._result_state(existing)
|
||||
existing_engine = self._result_engine(existing)
|
||||
if existing_state in {EngineState.STARTING, EngineState.RUNNING}:
|
||||
if existing_engine is not None and existing_engine is not request.engine:
|
||||
raise EngineError("another native engine is active; stop it before switching")
|
||||
existing_mode = self._result_mode(existing)
|
||||
if (
|
||||
request.mode is RunMode.BOTH
|
||||
and existing_mode is RunMode.VPN
|
||||
and request.proxy.access.value == "hotspot"
|
||||
):
|
||||
result = await self.bridge.start_hotspot_proxy(payload)
|
||||
self._request = request
|
||||
self._state = self._snapshot_from_result(result, request, EngineState.STARTING)
|
||||
return self._raise_if_terminal_error(self._state)
|
||||
self._request = request
|
||||
self._state = self._snapshot_from_result(existing, request, existing_state)
|
||||
return self._state
|
||||
self._request = request
|
||||
if request.mode is RunMode.PROXY:
|
||||
result = (
|
||||
await self.bridge.start_proxy(payload)
|
||||
if request.proxy.access.value == "local"
|
||||
else await self.bridge.start_hotspot_proxy(payload)
|
||||
)
|
||||
else:
|
||||
result = await self.bridge.start_vpn(payload)
|
||||
if request.mode is RunMode.BOTH and self._result_state(result) not in {
|
||||
EngineState.ERROR,
|
||||
EngineState.UNSUPPORTED,
|
||||
}:
|
||||
if request.proxy.access.value == "hotspot":
|
||||
await self.bridge.start_hotspot_proxy(payload)
|
||||
else:
|
||||
await self.bridge.start_proxy(payload)
|
||||
self._state = self._snapshot_from_result(result, request, EngineState.STARTING)
|
||||
return self._raise_if_terminal_error(self._state)
|
||||
|
||||
async def stop(self) -> RuntimeSnapshot:
|
||||
await self.bridge.stop_vpn()
|
||||
await self.bridge.stop_proxy()
|
||||
request = self._request
|
||||
self._state = RuntimeSnapshot(
|
||||
state=EngineState.STOPPED,
|
||||
engine=self.kind,
|
||||
mode=request.mode if request else None,
|
||||
profile_id=request.profile_id if request else None,
|
||||
)
|
||||
return self._state
|
||||
|
||||
async def restart(self, request: RuntimeRequest) -> RuntimeSnapshot:
|
||||
await self.stop()
|
||||
return await self.start(request)
|
||||
|
||||
async def get_state(self) -> RuntimeSnapshot:
|
||||
result = await self.bridge.get_runtime_state()
|
||||
request = self._request
|
||||
if isinstance(result, dict):
|
||||
if request is None:
|
||||
engine = self._result_engine(result)
|
||||
if engine is not self.kind:
|
||||
return self._state
|
||||
self._state = self._snapshot_from_result(result, None, self._state.state)
|
||||
else:
|
||||
self._state = self._snapshot_from_result(result, request, self._state.state)
|
||||
return self._state
|
||||
|
||||
async def get_stats(self) -> RuntimeStats:
|
||||
return self._state.stats
|
||||
|
||||
def _raise_if_terminal_error(self, snapshot: RuntimeSnapshot) -> RuntimeSnapshot:
|
||||
if snapshot.state is EngineState.ERROR:
|
||||
raise EngineError(snapshot.message or "native runtime failed")
|
||||
if snapshot.state is EngineState.UNSUPPORTED:
|
||||
raise UnsupportedEngineError(snapshot.message or "native engine is unsupported")
|
||||
return snapshot
|
||||
|
||||
@staticmethod
|
||||
def _result_state(result: object) -> EngineState:
|
||||
if isinstance(result, dict):
|
||||
try:
|
||||
return EngineState(str(result.get("state", "starting")))
|
||||
except ValueError:
|
||||
return EngineState.STARTING
|
||||
return EngineState.STARTING
|
||||
|
||||
@staticmethod
|
||||
def _result_engine(result: object) -> EngineKind | None:
|
||||
if not isinstance(result, dict):
|
||||
return None
|
||||
value = result.get("engine")
|
||||
try:
|
||||
return EngineKind(str(value)) if value else None
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
def _snapshot_from_result(
|
||||
self, result: object, request: RuntimeRequest | None, fallback: EngineState
|
||||
) -> RuntimeSnapshot:
|
||||
state = self._result_state(result)
|
||||
if state is EngineState.STOPPED and fallback is EngineState.STARTING:
|
||||
state = fallback
|
||||
message = result.get("message") if isinstance(result, dict) else None
|
||||
engine = request.engine if request else self._result_engine(result)
|
||||
mode = request.mode if request else self._result_mode(result)
|
||||
profile_id = request.profile_id if request else self._result_string(result, "profile_id")
|
||||
payload = self._result_payload(result)
|
||||
return RuntimeSnapshot(
|
||||
state=state,
|
||||
engine=engine,
|
||||
mode=mode,
|
||||
profile_id=profile_id,
|
||||
message=str(message) if message else None,
|
||||
proxy_addresses=self._proxy_addresses(payload),
|
||||
captcha_url=payload.get("captcha_url") if isinstance(payload.get("captcha_url"), str) else None,
|
||||
revision=request.config_revision if request else 0,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _result_string(result: object, key: str) -> str | None:
|
||||
value = result.get(key) if isinstance(result, dict) else None
|
||||
return str(value) if value else None
|
||||
|
||||
@staticmethod
|
||||
def _result_mode(result: object):
|
||||
from app.models import RunMode
|
||||
|
||||
value = result.get("mode") if isinstance(result, dict) else None
|
||||
try:
|
||||
return RunMode(str(value)) if value else None
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _result_payload(result: object) -> dict[str, object]:
|
||||
if not isinstance(result, dict):
|
||||
return {}
|
||||
payload = result.get("payload")
|
||||
if isinstance(payload, dict):
|
||||
return payload
|
||||
if isinstance(payload, str):
|
||||
try:
|
||||
import json
|
||||
|
||||
decoded = json.loads(payload)
|
||||
return decoded if isinstance(decoded, dict) else {}
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def _proxy_addresses(payload: dict[str, object]) -> list[str]:
|
||||
addresses = payload.get("addresses")
|
||||
if isinstance(addresses, list):
|
||||
return [str(item) for item in addresses]
|
||||
return []
|
||||
36
app/core/proxy.py
Normal file
36
app/core/proxy.py
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import socket
|
||||
from collections.abc import Iterable
|
||||
|
||||
|
||||
def pick_free_port(host: str = "127.0.0.1", candidates: Iterable[int] | None = None) -> int:
|
||||
"""Choose an available TCP port without binding a privileged/system port."""
|
||||
|
||||
ports = list(candidates or range(49152, 65536))
|
||||
for port in ports:
|
||||
with socket.socket(
|
||||
socket.AF_INET6 if ":" in host else socket.AF_INET, socket.SOCK_STREAM
|
||||
) as sock:
|
||||
try:
|
||||
sock.bind((host, port))
|
||||
except OSError:
|
||||
continue
|
||||
return port
|
||||
raise OSError("no free high TCP port")
|
||||
|
||||
|
||||
async def drain_process_output(process: asyncio.subprocess.Process) -> list[str]:
|
||||
"""Read both streams until a process exits, returning sanitized text lines."""
|
||||
|
||||
lines: list[str] = []
|
||||
|
||||
async def read(stream: asyncio.StreamReader | None) -> None:
|
||||
if stream is None:
|
||||
return
|
||||
while line := await stream.readline():
|
||||
lines.append(line.decode("utf-8", errors="replace").rstrip())
|
||||
|
||||
await asyncio.gather(read(process.stdout), read(process.stderr))
|
||||
return lines
|
||||
27
app/core/registry.py
Normal file
27
app/core/registry.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
from app.core.interface import EngineBackend
|
||||
from app.models import EngineKind
|
||||
|
||||
BackendFactory = Callable[[], EngineBackend]
|
||||
|
||||
|
||||
class BackendRegistry:
|
||||
def __init__(self) -> None:
|
||||
self._factories: dict[EngineKind, BackendFactory] = {}
|
||||
|
||||
def register(self, kind: EngineKind, factory: BackendFactory) -> None:
|
||||
if kind in self._factories:
|
||||
raise ValueError(f"backend already registered: {kind}")
|
||||
self._factories[kind] = factory
|
||||
|
||||
def create(self, kind: EngineKind) -> EngineBackend:
|
||||
try:
|
||||
return self._factories[kind]()
|
||||
except KeyError as exc:
|
||||
raise KeyError(f"backend is not registered: {kind}") from exc
|
||||
|
||||
def kinds(self) -> tuple[EngineKind, ...]:
|
||||
return tuple(self._factories)
|
||||
153
app/core/signbox.py
Normal file
153
app/core/signbox.py
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.errors import UnsupportedEngineError
|
||||
from app.core.interface import EngineBackend
|
||||
from app.models import (
|
||||
CapabilitySet,
|
||||
EngineKind,
|
||||
EngineState,
|
||||
RuntimeRequest,
|
||||
RuntimeSnapshot,
|
||||
RuntimeStats,
|
||||
)
|
||||
|
||||
|
||||
class SignBoxBackend(EngineBackend):
|
||||
"""SignBox/sing-box adapter.
|
||||
|
||||
Desktop proxy mode is intentionally process based. Android must use the native
|
||||
libbox AAR through `native/android`; this Python class never starts a subprocess
|
||||
on Android.
|
||||
"""
|
||||
|
||||
name = "signbox"
|
||||
capabilities = CapabilitySet(
|
||||
supports_vpn=False,
|
||||
supports_proxy=True,
|
||||
supports_socks=True,
|
||||
supports_http=True,
|
||||
supports_udp=True,
|
||||
supports_mobile=False,
|
||||
supports_desktop=True,
|
||||
supports_stats=True,
|
||||
supports_both=False,
|
||||
)
|
||||
|
||||
def __init__(self, executable: str | None = None, state_dir: Path | None = None) -> None:
|
||||
self.executable = executable or os.getenv("SIGNBOX_BIN")
|
||||
self.capabilities = CapabilitySet(
|
||||
supports_proxy=bool(self.executable),
|
||||
supports_socks=bool(self.executable),
|
||||
supports_http=bool(self.executable),
|
||||
supports_udp=bool(self.executable),
|
||||
supports_desktop=bool(self.executable),
|
||||
supports_stats=bool(self.executable),
|
||||
)
|
||||
self.state_dir = state_dir or Path(".runtime")
|
||||
self._process: asyncio.subprocess.Process | None = None
|
||||
self._monitor_task: asyncio.Task[None] | None = None
|
||||
self._state = RuntimeSnapshot(engine=EngineKind.SIGNBOX)
|
||||
self._request: RuntimeRequest | None = None
|
||||
|
||||
async def start(self, request: RuntimeRequest) -> RuntimeSnapshot:
|
||||
self._request = request
|
||||
self._state = RuntimeSnapshot(
|
||||
state=EngineState.STARTING,
|
||||
engine=EngineKind.SIGNBOX,
|
||||
mode=request.mode,
|
||||
profile_id=request.profile_id,
|
||||
revision=request.config_revision,
|
||||
)
|
||||
if not self.executable:
|
||||
self._state = self._unsupported("SIGNBOX_BIN is not configured for desktop")
|
||||
raise UnsupportedEngineError(self._state.message or "SignBox binary is missing")
|
||||
if request.mode.value != "proxy":
|
||||
self._state = self._unsupported(
|
||||
"desktop SignBox adapter currently supports proxy-only; Android uses native libbox"
|
||||
)
|
||||
raise UnsupportedEngineError(self._state.message or "unsupported desktop mode")
|
||||
if not request.engine_config:
|
||||
self._state = self._unsupported("SignBox profile config is empty")
|
||||
raise UnsupportedEngineError(self._state.message or "SignBox config is empty")
|
||||
self.state_dir.mkdir(parents=True, exist_ok=True)
|
||||
config_path = self.state_dir / f"signbox-{request.profile_id}.json"
|
||||
config_path.write_text(
|
||||
json.dumps(request.engine_config, indent=2), encoding="utf-8"
|
||||
)
|
||||
self._process = await asyncio.create_subprocess_exec(
|
||||
self.executable,
|
||||
"run",
|
||||
"-c",
|
||||
str(config_path),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
self._monitor_task = asyncio.create_task(self._monitor(self._process))
|
||||
await asyncio.sleep(0)
|
||||
if self._process.returncode is not None:
|
||||
self._state = self._state.model_copy(
|
||||
update={"state": EngineState.ERROR, "message": "SignBox exited during startup"}
|
||||
)
|
||||
raise UnsupportedEngineError(self._state.message or "SignBox exited during startup")
|
||||
self._state = self._state.model_copy(update={"state": EngineState.RUNNING})
|
||||
return self._state
|
||||
|
||||
async def stop(self) -> RuntimeSnapshot:
|
||||
if self._monitor_task is not None:
|
||||
self._monitor_task.cancel()
|
||||
self._monitor_task = None
|
||||
if self._process is not None:
|
||||
self._process.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(self._process.wait(), timeout=5)
|
||||
except TimeoutError:
|
||||
self._process.kill()
|
||||
await self._process.wait()
|
||||
self._process = None
|
||||
self._state = self._state.model_copy(update={"state": EngineState.STOPPED})
|
||||
return self._state
|
||||
|
||||
async def _monitor(self, process: asyncio.subprocess.Process) -> None:
|
||||
async def drain(stream: asyncio.StreamReader | None) -> None:
|
||||
if stream is None:
|
||||
return
|
||||
while await stream.readline():
|
||||
pass
|
||||
|
||||
try:
|
||||
await asyncio.gather(drain(process.stdout), drain(process.stderr))
|
||||
await process.wait()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
if self._process is process and self._state.state not in {
|
||||
EngineState.STOPPED,
|
||||
EngineState.STOPPING,
|
||||
}:
|
||||
self._state = self._state.model_copy(
|
||||
update={"state": EngineState.ERROR, "message": "SignBox process exited unexpectedly"}
|
||||
)
|
||||
|
||||
async def restart(self, request: RuntimeRequest) -> RuntimeSnapshot:
|
||||
await self.stop()
|
||||
return await self.start(request)
|
||||
|
||||
async def get_state(self) -> RuntimeSnapshot:
|
||||
if self._process is not None and self._process.returncode is not None:
|
||||
self._state = self._state.model_copy(
|
||||
update={
|
||||
"state": EngineState.ERROR,
|
||||
"message": "SignBox process exited unexpectedly",
|
||||
}
|
||||
)
|
||||
return self._state
|
||||
|
||||
async def get_stats(self) -> RuntimeStats:
|
||||
return self._state.stats
|
||||
|
||||
def _unsupported(self, message: str) -> RuntimeSnapshot:
|
||||
return self._state.model_copy(update={"state": EngineState.UNSUPPORTED, "message": message})
|
||||
124
app/main.py
Normal file
124
app/main.py
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import flet as ft
|
||||
from vpn_native_bridge import NativeBridge
|
||||
|
||||
from app.models import PaymentInfo
|
||||
from app.screens.safe_area import safe_area
|
||||
from app.screens.settings import build_settings_screen
|
||||
from app.screens.vpn import build_vpn_screen
|
||||
from app.screens.white_lists import build_white_lists_screen
|
||||
from app.services.billing import month_key, payment_banner
|
||||
from app.services.controller import RuntimeController
|
||||
from app.services.settings_registry import SettingsRegistry
|
||||
from app.services.state_store import StateStore
|
||||
from app.theme import apply_accent
|
||||
|
||||
|
||||
def main(page: ft.Page) -> None:
|
||||
page.title = "VPN App"
|
||||
page.padding = 0
|
||||
page.scroll = ft.ScrollMode.AUTO
|
||||
page.theme_mode = ft.ThemeMode.SYSTEM
|
||||
store = StateStore()
|
||||
persisted = store.load()
|
||||
native_bridge = (
|
||||
NativeBridge()
|
||||
if page.platform in {ft.PagePlatform.ANDROID, ft.PagePlatform.ANDROID_TV}
|
||||
else None
|
||||
)
|
||||
controller = RuntimeController(native_bridge=native_bridge)
|
||||
registry = SettingsRegistry()
|
||||
payment = PaymentInfo(
|
||||
monthly_price_rub=int(persisted.get("monthly_price_rub", 0)),
|
||||
payment_day=int(persisted.get("payment_day", 1)),
|
||||
)
|
||||
banner = ft.Ref[ft.Banner]()
|
||||
|
||||
def dismiss_payment(_: ft.ControlEvent) -> None:
|
||||
state = store.load()
|
||||
state["payment_banner_dismissed"] = month_key()
|
||||
store.save(state)
|
||||
page.close(banner.current)
|
||||
|
||||
def maybe_show_payment() -> None:
|
||||
if payment.monthly_price_rub <= 0:
|
||||
return
|
||||
if persisted.get("payment_banner_dismissed") == month_key():
|
||||
return
|
||||
message = payment_banner(payment)
|
||||
if message:
|
||||
page.open(
|
||||
ft.Banner(
|
||||
ref=banner,
|
||||
leading=ft.Icon(ft.Icons.INFO_OUTLINE),
|
||||
content=ft.Text(message),
|
||||
actions=[
|
||||
ft.TextButton("Не показывать в этом месяце", on_click=dismiss_payment)
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
def set_theme(key: str) -> None:
|
||||
apply_accent(page, key)
|
||||
|
||||
vpn_screen = build_vpn_screen(page, controller, registry, set_theme)
|
||||
white_lists_screen = build_white_lists_screen(
|
||||
page, controller, registry, lambda _: set_theme("freeturn")
|
||||
)
|
||||
settings_screen = build_settings_screen(page, registry)
|
||||
|
||||
def open_settings(_: ft.ControlEvent) -> None:
|
||||
dialog = ft.AlertDialog(
|
||||
modal=True,
|
||||
title=ft.Text("Настройки"),
|
||||
content=ft.Container(settings_screen, width=460, height=520),
|
||||
actions=[ft.TextButton("Закрыть", on_click=lambda _: page.close(dialog))],
|
||||
)
|
||||
page.open(dialog)
|
||||
|
||||
def tab_changed(event: ft.ControlEvent) -> None:
|
||||
index = event.control.selected_index
|
||||
set_theme("freeturn" if index == 1 else "basic")
|
||||
|
||||
page.appbar = ft.AppBar(
|
||||
title=ft.Text("VPN App"),
|
||||
actions=[ft.IconButton(ft.Icons.SETTINGS_OUTLINED, on_click=open_settings)],
|
||||
)
|
||||
tabs = ft.Tabs(
|
||||
expand=True,
|
||||
selected_index=0,
|
||||
tabs=[
|
||||
ft.Tab(text="VPN", content=vpn_screen),
|
||||
ft.Tab(text="Белые списки", content=white_lists_screen),
|
||||
],
|
||||
on_change=tab_changed,
|
||||
)
|
||||
page.add(
|
||||
safe_area(
|
||||
ft.Column(
|
||||
[tabs],
|
||||
expand=True,
|
||||
)
|
||||
)
|
||||
)
|
||||
apply_accent(page, "basic")
|
||||
maybe_show_payment()
|
||||
|
||||
if native_bridge is not None:
|
||||
async def restore_native_state() -> None:
|
||||
while True:
|
||||
await controller.refresh()
|
||||
if controller.engine_kind.value == "freeturn":
|
||||
tabs.selected_index = 1
|
||||
set_theme("freeturn")
|
||||
page.update()
|
||||
await asyncio.sleep(2)
|
||||
|
||||
page.run_task(restore_native_state)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
ft.run(main)
|
||||
199
app/models.py
Normal file
199
app/models.py
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import calendar
|
||||
import secrets
|
||||
import uuid
|
||||
from datetime import UTC, date, datetime
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
def utc_now() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
class EngineState(StrEnum):
|
||||
STOPPED = "stopped"
|
||||
STARTING = "starting"
|
||||
RUNNING = "running"
|
||||
STOPPING = "stopping"
|
||||
ERROR = "error"
|
||||
UNSUPPORTED = "unsupported"
|
||||
|
||||
|
||||
class EngineKind(StrEnum):
|
||||
SIGNBOX = "signbox"
|
||||
FREETURN = "freeturn"
|
||||
|
||||
|
||||
class RunMode(StrEnum):
|
||||
VPN = "vpn"
|
||||
PROXY = "proxy"
|
||||
BOTH = "both"
|
||||
|
||||
|
||||
class ProxyAccess(StrEnum):
|
||||
LOCAL = "local"
|
||||
HOTSPOT = "hotspot"
|
||||
|
||||
|
||||
class ProxyProtocol(StrEnum):
|
||||
SOCKS5 = "socks5"
|
||||
HTTP = "http"
|
||||
|
||||
|
||||
class AppPolicy(StrEnum):
|
||||
PROXY = "proxy"
|
||||
DIRECT = "direct"
|
||||
VOID = "void"
|
||||
|
||||
|
||||
class Strategy(StrEnum):
|
||||
BASIC = "basic"
|
||||
ENHANCED = "enhanced"
|
||||
|
||||
|
||||
class CapabilitySet(BaseModel):
|
||||
supports_vpn: bool = False
|
||||
supports_proxy: bool = False
|
||||
supports_socks: bool = False
|
||||
supports_http: bool = False
|
||||
supports_udp: bool = False
|
||||
supports_mobile: bool = False
|
||||
supports_desktop: bool = False
|
||||
supports_stats: bool = False
|
||||
supports_hot_reload: bool = False
|
||||
supports_both: bool = False
|
||||
|
||||
|
||||
class ProfileMetadata(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||
name: str = "Новый профиль"
|
||||
created_at: datetime = Field(default_factory=utc_now)
|
||||
updated_at: datetime = Field(default_factory=utc_now)
|
||||
|
||||
|
||||
class SignBoxProfile(ProfileMetadata):
|
||||
kind: EngineKind = EngineKind.SIGNBOX
|
||||
strategy: Strategy = Strategy.BASIC
|
||||
config: dict[str, Any] = Field(default_factory=dict)
|
||||
mtu: int = Field(default=1280, ge=576, le=9000)
|
||||
split_tunneling: bool = False
|
||||
raw_config: str | None = None
|
||||
|
||||
|
||||
class FreeTurnProfile(ProfileMetadata):
|
||||
kind: EngineKind = EngineKind.FREETURN
|
||||
key: str = ""
|
||||
options: dict[str, Any] = Field(default_factory=dict)
|
||||
preset: int = Field(default=1, ge=1, le=4)
|
||||
stability_over_load: bool = False
|
||||
captcha_always_browser: bool = False
|
||||
|
||||
@property
|
||||
def stream_settings(self) -> tuple[int, int]:
|
||||
values = {1: (4, 4), 2: (8, 4), 3: (12, 6), 4: (24, 6)}
|
||||
threads, per_credential = values[self.preset]
|
||||
if self.stability_over_load and self.preset >= 3:
|
||||
per_credential = 4
|
||||
return threads, per_credential
|
||||
|
||||
|
||||
class ProxySettings(BaseModel):
|
||||
enabled: bool = True
|
||||
access: ProxyAccess = ProxyAccess.LOCAL
|
||||
socks_enabled: bool = True
|
||||
socks_port: int = Field(default=1080, ge=1, le=65535)
|
||||
socks_random_port: bool = False
|
||||
http_enabled: bool = True
|
||||
http_port: int = Field(default=8080, ge=1, le=65535)
|
||||
http_random_port: bool = False
|
||||
authentication: bool = False
|
||||
username: str = ""
|
||||
password: str = ""
|
||||
bind_address: str = "127.0.0.1"
|
||||
|
||||
@field_validator("username", "password")
|
||||
@classmethod
|
||||
def no_control_chars(cls, value: str) -> str:
|
||||
if any(ord(char) < 32 for char in value):
|
||||
raise ValueError("credentials must not contain control characters")
|
||||
return value
|
||||
|
||||
def validate_for_access(self) -> None:
|
||||
if self.access is ProxyAccess.HOTSPOT and not self.authentication:
|
||||
return
|
||||
if self.authentication and (not self.username or not self.password):
|
||||
raise ValueError("proxy authentication requires username and password")
|
||||
|
||||
|
||||
class VpnSettings(BaseModel):
|
||||
mode: RunMode = RunMode.VPN
|
||||
tun_address: str = "10.0.0.1"
|
||||
tun_prefix: int = Field(default=24, ge=8, le=30)
|
||||
mtu: int = Field(default=1280, ge=576, le=9000)
|
||||
split_tunneling: bool = False
|
||||
app_rules: dict[str, AppPolicy] = Field(default_factory=dict)
|
||||
|
||||
@field_validator("app_rules")
|
||||
@classmethod
|
||||
def validate_rules(cls, value: dict[str, AppPolicy]) -> dict[str, AppPolicy]:
|
||||
# A VOID rule must never be represented as a VpnService exclusion.
|
||||
return dict(value)
|
||||
|
||||
|
||||
class RuntimeRequest(BaseModel):
|
||||
engine: EngineKind
|
||||
mode: RunMode
|
||||
profile_id: str
|
||||
proxy: ProxySettings = Field(default_factory=ProxySettings)
|
||||
vpn: VpnSettings = Field(default_factory=VpnSettings)
|
||||
engine_config: dict[str, Any] = Field(default_factory=dict)
|
||||
config_revision: int = Field(default=0, ge=0)
|
||||
|
||||
|
||||
class RuntimeStats(BaseModel):
|
||||
upload_bytes: int = 0
|
||||
download_bytes: int = 0
|
||||
upload_bps: int = 0
|
||||
download_bps: int = 0
|
||||
uptime_seconds: int = 0
|
||||
|
||||
|
||||
class RuntimeSnapshot(BaseModel):
|
||||
state: EngineState = EngineState.STOPPED
|
||||
engine: EngineKind | None = None
|
||||
mode: RunMode | None = None
|
||||
profile_id: str | None = None
|
||||
message: str | None = None
|
||||
stats: RuntimeStats = Field(default_factory=RuntimeStats)
|
||||
proxy_addresses: list[str] = Field(default_factory=list)
|
||||
captcha_url: str | None = None
|
||||
revision: int = 0
|
||||
|
||||
|
||||
class PaymentInfo(BaseModel):
|
||||
monthly_price_rub: int = Field(ge=0)
|
||||
payment_day: int = Field(ge=1, le=31)
|
||||
|
||||
def due_date(self, year: int, month: int) -> date:
|
||||
last_day = calendar.monthrange(year, month)[1]
|
||||
return date(year, month, min(self.payment_day, last_day))
|
||||
|
||||
|
||||
class DeviceSubscription(BaseModel):
|
||||
device_id: str
|
||||
device_token: str
|
||||
subscription_url: str
|
||||
|
||||
|
||||
def generate_device_token() -> str:
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def generate_device_id() -> str:
|
||||
return str(uuid.uuid4())
|
||||
1
app/screens/__init__.py
Normal file
1
app/screens/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Small Flet screens."""
|
||||
52
app/screens/components.py
Normal file
52
app/screens/components.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
import flet as ft
|
||||
|
||||
from app.models import EngineState, RunMode
|
||||
from app.theme import card
|
||||
|
||||
|
||||
def connection_card(
|
||||
state: EngineState,
|
||||
mode: RunMode,
|
||||
backend: str,
|
||||
on_slide: Callable[[ft.ControlEvent], object],
|
||||
enabled: bool = True,
|
||||
) -> ft.Control:
|
||||
labels = {
|
||||
EngineState.STOPPED: "Отключено",
|
||||
EngineState.STARTING: "Подключение…",
|
||||
EngineState.RUNNING: "Подключено",
|
||||
EngineState.STOPPING: "Отключение…",
|
||||
EngineState.ERROR: "Ошибка",
|
||||
EngineState.UNSUPPORTED: "Недоступно",
|
||||
}
|
||||
return card(
|
||||
ft.Column(
|
||||
[
|
||||
ft.Text(labels[state], size=26, weight=ft.FontWeight.W_600),
|
||||
ft.Text(f"{backend} · {mode.value}", color=ft.Colors.OUTLINE),
|
||||
ft.Slider(
|
||||
min=0,
|
||||
max=1,
|
||||
divisions=1,
|
||||
value=1 if state in {EngineState.RUNNING, EngineState.STARTING} else 0,
|
||||
label="Подключено"
|
||||
if state is EngineState.RUNNING
|
||||
else "Проведите для подключения" if enabled else "Недоступно",
|
||||
disabled=not enabled,
|
||||
on_change=on_slide if enabled else None,
|
||||
),
|
||||
],
|
||||
spacing=8,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def section_title(title: str, subtitle: str | None = None) -> ft.Control:
|
||||
controls: list[ft.Control] = [ft.Text(title, size=20, weight=ft.FontWeight.W_600)]
|
||||
if subtitle:
|
||||
controls.append(ft.Text(subtitle, color=ft.Colors.OUTLINE))
|
||||
return ft.Column(controls, spacing=2)
|
||||
7
app/screens/safe_area.py
Normal file
7
app/screens/safe_area.py
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import flet as ft
|
||||
|
||||
|
||||
def safe_area(content: ft.Control) -> ft.SafeArea:
|
||||
return ft.SafeArea(content=ft.Container(content=content, expand=True, padding=12))
|
||||
39
app/screens/settings.py
Normal file
39
app/screens/settings.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import flet as ft
|
||||
|
||||
from app.services.settings_registry import SettingsRegistry
|
||||
from app.theme import card
|
||||
|
||||
|
||||
def build_settings_screen(page: ft.Page, registry: SettingsRegistry) -> ft.Control:
|
||||
body = ft.Column(spacing=14, scroll=ft.ScrollMode.AUTO, expand=True)
|
||||
sections = registry.sections()
|
||||
body.controls = [
|
||||
ft.Text("Настройки", size=22, weight=ft.FontWeight.W_600),
|
||||
card(
|
||||
ft.Column(
|
||||
[
|
||||
ft.Text("Динамические разделы", weight=ft.FontWeight.W_600),
|
||||
*[
|
||||
ft.Text(f"{item.title}: доступно из соответствующего экрана")
|
||||
for item in sections
|
||||
],
|
||||
],
|
||||
spacing=8,
|
||||
)
|
||||
),
|
||||
card(
|
||||
ft.Column(
|
||||
[
|
||||
ft.Text("Приватность", weight=ft.FontWeight.W_600),
|
||||
ft.Text(
|
||||
"Management/debug API выключены. Секреты не выводятся в логи.",
|
||||
color=ft.Colors.OUTLINE,
|
||||
),
|
||||
],
|
||||
spacing=8,
|
||||
)
|
||||
),
|
||||
]
|
||||
return body
|
||||
188
app/screens/vpn.py
Normal file
188
app/screens/vpn.py
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
|
||||
import flet as ft
|
||||
import qrcode
|
||||
|
||||
from app.models import EngineState, RunMode, Strategy
|
||||
from app.screens.components import connection_card, section_title
|
||||
from app.services.controller import RuntimeController
|
||||
from app.services.settings_registry import SettingsRegistry
|
||||
from app.theme import card
|
||||
|
||||
|
||||
def build_vpn_screen(
|
||||
page: ft.Page,
|
||||
controller: RuntimeController,
|
||||
registry: SettingsRegistry,
|
||||
on_theme: callable,
|
||||
) -> ft.Control:
|
||||
content = ft.Column(expand=True, scroll=ft.ScrollMode.AUTO, spacing=14)
|
||||
is_android = page.platform in {ft.PagePlatform.ANDROID, ft.PagePlatform.ANDROID_TV}
|
||||
if not is_android:
|
||||
controller.mode = RunMode.PROXY
|
||||
|
||||
def strategy_changed(event: ft.ControlEvent) -> None:
|
||||
controller.select_strategy(
|
||||
Strategy.ENHANCED if event.control.selected_index else Strategy.BASIC
|
||||
)
|
||||
on_theme(controller.strategy.value)
|
||||
render()
|
||||
|
||||
def mode_changed(event: ft.ControlEvent) -> None:
|
||||
selected = event.control.selected
|
||||
if selected:
|
||||
controller.mode = RunMode(selected[0])
|
||||
render()
|
||||
|
||||
async def slider_changed(event: ft.ControlEvent) -> None:
|
||||
if float(event.control.value or 0) < 0.8:
|
||||
return
|
||||
try:
|
||||
await controller.connect()
|
||||
except Exception as exc: # UI must show honest unsupported/error state.
|
||||
controller.snapshot = controller.snapshot.model_copy(
|
||||
update={"state": EngineState.ERROR, "message": controller.error_text(exc)}
|
||||
)
|
||||
render()
|
||||
|
||||
async def stop_click(_: ft.ControlEvent) -> None:
|
||||
await controller.stop()
|
||||
render()
|
||||
|
||||
async def share_click(_: ft.ControlEvent) -> None:
|
||||
try:
|
||||
await controller.start_hotspot_share()
|
||||
except Exception as exc:
|
||||
controller.snapshot = controller.snapshot.model_copy(
|
||||
update={"state": EngineState.ERROR, "message": controller.error_text(exc)}
|
||||
)
|
||||
render()
|
||||
if controller.snapshot.proxy_addresses:
|
||||
show_share_dialog()
|
||||
|
||||
def show_share_dialog() -> None:
|
||||
text = "\n".join(controller.snapshot.proxy_addresses)
|
||||
image = io.BytesIO()
|
||||
qrcode.make(text).save(image, format="PNG")
|
||||
dialog = ft.AlertDialog(
|
||||
modal=True,
|
||||
title=ft.Text("Поделиться через Wi-Fi"),
|
||||
content=ft.Column(
|
||||
[
|
||||
ft.Text("Подключите устройство к показанной точке доступа."),
|
||||
ft.Text(text, selectable=True),
|
||||
ft.Image(
|
||||
src_base64=base64.b64encode(image.getvalue()).decode("ascii"),
|
||||
width=220,
|
||||
height=220,
|
||||
),
|
||||
],
|
||||
tight=True,
|
||||
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
|
||||
),
|
||||
actions=[ft.TextButton("Закрыть", on_click=lambda _: page.close(dialog))],
|
||||
)
|
||||
page.open(dialog)
|
||||
|
||||
def render() -> None:
|
||||
snapshot = controller.snapshot
|
||||
backend = "SignBox"
|
||||
capabilities = controller.capabilities()
|
||||
engine_ready = capabilities.supports_proxy or capabilities.supports_vpn
|
||||
mode_segments = []
|
||||
if is_android and capabilities.supports_vpn:
|
||||
mode_segments.append(ft.Segment(value=RunMode.VPN.value, label=ft.Text("TUN")))
|
||||
if capabilities.supports_proxy:
|
||||
mode_segments.append(ft.Segment(value=RunMode.PROXY.value, label=ft.Text("Proxy")))
|
||||
if is_android and capabilities.supports_both:
|
||||
mode_segments.append(ft.Segment(value=RunMode.BOTH.value, label=ft.Text("Both")))
|
||||
if not mode_segments:
|
||||
mode_segments = [ft.Segment(value=RunMode.PROXY.value, label=ft.Text("Proxy"))]
|
||||
if controller.mode is not RunMode.PROXY:
|
||||
controller.mode = RunMode.PROXY
|
||||
content.controls = [
|
||||
section_title("VPN", "Выберите стратегию и потяните слайдер вправо"),
|
||||
ft.Tabs(
|
||||
selected_index=0 if controller.strategy is Strategy.BASIC else 1,
|
||||
tabs=[
|
||||
ft.Tab(text="Основная", icon=ft.Icons.LIGHT_MODE),
|
||||
ft.Tab(text="Усиленная", icon=ft.Icons.SHIELD),
|
||||
],
|
||||
on_change=strategy_changed,
|
||||
),
|
||||
ft.SegmentedButton(
|
||||
selected=[controller.mode.value],
|
||||
segments=mode_segments,
|
||||
on_change=mode_changed,
|
||||
),
|
||||
connection_card(snapshot.state, controller.mode, backend, slider_changed, engine_ready),
|
||||
ft.Row(
|
||||
[
|
||||
ft.OutlinedButton("Остановить", icon=ft.Icons.STOP, on_click=stop_click),
|
||||
*(
|
||||
[
|
||||
ft.FloatingActionButton(
|
||||
icon=ft.Icons.WIFI,
|
||||
tooltip="Поделиться через Wi-Fi",
|
||||
on_click=share_click,
|
||||
)
|
||||
]
|
||||
if is_android and capabilities.supports_both
|
||||
else []
|
||||
),
|
||||
]
|
||||
),
|
||||
card(
|
||||
ft.Column(
|
||||
[
|
||||
ft.Text("Параметры", weight=ft.FontWeight.W_600),
|
||||
ft.Text(f"MTU: {controller.vpn.mtu}"),
|
||||
ft.Slider(
|
||||
min=576,
|
||||
max=1500,
|
||||
divisions=77,
|
||||
value=controller.vpn.mtu,
|
||||
label="{value}",
|
||||
on_change=lambda e: setattr(controller.vpn, "mtu", int(e.control.value)),
|
||||
),
|
||||
ft.Switch(
|
||||
label="Split-tunneling",
|
||||
value=controller.vpn.split_tunneling,
|
||||
disabled=not is_android,
|
||||
on_change=lambda e: setattr(
|
||||
controller.vpn, "split_tunneling", bool(e.control.value)
|
||||
),
|
||||
),
|
||||
ft.Text(
|
||||
"Режимы TUN/Both и split-tunneling доступны на Android; desktop — proxy-only.",
|
||||
color=ft.Colors.OUTLINE,
|
||||
),
|
||||
*([
|
||||
ft.Text(
|
||||
"SignBox native artifact/API ещё не подключён; подключение недоступно.",
|
||||
color=ft.Colors.ERROR,
|
||||
)
|
||||
] if not engine_ready else []),
|
||||
ft.Text(
|
||||
"Настройки стратегии подключаются через этот экран.",
|
||||
color=ft.Colors.OUTLINE,
|
||||
),
|
||||
ft.Text(
|
||||
snapshot.message or "",
|
||||
color=ft.Colors.ERROR if snapshot.message else None,
|
||||
),
|
||||
],
|
||||
spacing=8,
|
||||
)
|
||||
),
|
||||
]
|
||||
if page is not None:
|
||||
page.update()
|
||||
|
||||
registry.register("vpn", "VPN", lambda: content)
|
||||
controller.add_listener(render)
|
||||
render()
|
||||
return content
|
||||
155
app/screens/white_lists.py
Normal file
155
app/screens/white_lists.py
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import flet as ft
|
||||
import flet_webview as fwv
|
||||
|
||||
from app.models import FreeTurnProfile
|
||||
from app.screens.components import connection_card, section_title
|
||||
from app.services.controller import RuntimeController
|
||||
from app.services.settings_registry import SettingsRegistry
|
||||
from app.theme import card
|
||||
|
||||
PRESET_TEXT = {
|
||||
1: "4/4 · для сообщений",
|
||||
2: "8/4 · для простого интернет-сёрфинга",
|
||||
3: "12/6 · для видео в невысоком качестве",
|
||||
4: "24/6 · для всего · ультра скорость, повышенная нагрузка",
|
||||
}
|
||||
CAPTCHA_ENTRY_URL = "https://vk.com"
|
||||
|
||||
|
||||
def build_white_lists_screen(
|
||||
page: ft.Page,
|
||||
controller: RuntimeController,
|
||||
registry: SettingsRegistry,
|
||||
on_theme: callable,
|
||||
) -> ft.Control:
|
||||
content = ft.Column(expand=True, scroll=ft.ScrollMode.AUTO, spacing=14)
|
||||
|
||||
async def slide(event: ft.ControlEvent) -> None:
|
||||
if float(event.control.value or 0) < 0.8:
|
||||
return
|
||||
try:
|
||||
controller.select_freeturn()
|
||||
await controller.connect()
|
||||
except Exception as exc:
|
||||
controller.snapshot = controller.snapshot.model_copy(
|
||||
update={"state": "error", "message": controller.error_text(exc)}
|
||||
)
|
||||
render()
|
||||
|
||||
async def stop(_: ft.ControlEvent) -> None:
|
||||
await controller.stop()
|
||||
render()
|
||||
|
||||
def preset_change(event: ft.ControlEvent) -> None:
|
||||
controller.freeturn.preset = int(round(float(event.control.value)))
|
||||
render()
|
||||
|
||||
def stability_change(event: ft.ControlEvent) -> None:
|
||||
controller.freeturn.stability_over_load = bool(event.control.value)
|
||||
render()
|
||||
|
||||
def captcha_settings() -> ft.Control:
|
||||
captcha_url = controller.snapshot.captcha_url or CAPTCHA_ENTRY_URL
|
||||
|
||||
def open_captcha(_: ft.ControlEvent, in_browser: bool = False) -> None:
|
||||
if in_browser or controller.freeturn.captcha_always_browser:
|
||||
page.launch_url(captcha_url)
|
||||
return
|
||||
dialog = ft.AlertDialog(
|
||||
modal=True,
|
||||
title=ft.Text("CAPTCHA"),
|
||||
content=ft.Container(
|
||||
fwv.WebView(url=captcha_url, expand=True),
|
||||
width=520,
|
||||
height=620,
|
||||
),
|
||||
actions=[
|
||||
ft.TextButton(
|
||||
"Открыть в браузере",
|
||||
on_click=lambda _: page.launch_url(captcha_url),
|
||||
),
|
||||
ft.TextButton("Закрыть", on_click=lambda _: page.close(dialog)),
|
||||
],
|
||||
)
|
||||
page.open(dialog)
|
||||
|
||||
return card(
|
||||
ft.Column(
|
||||
[
|
||||
ft.Text("CAPTCHA", weight=ft.FontWeight.W_600),
|
||||
ft.Text(
|
||||
"Встроенное окно доступно на Android. На desktop используйте браузер.",
|
||||
color=ft.Colors.OUTLINE,
|
||||
),
|
||||
ft.Switch(
|
||||
label="Всегда открывать CAPTCHA в браузере",
|
||||
value=controller.freeturn.captcha_always_browser,
|
||||
on_change=lambda e: setattr(
|
||||
controller.freeturn, "captcha_always_browser", bool(e.control.value)
|
||||
),
|
||||
),
|
||||
ft.Row(
|
||||
[
|
||||
ft.FilledButton(
|
||||
"Открыть внутри приложения",
|
||||
on_click=open_captcha,
|
||||
),
|
||||
ft.OutlinedButton(
|
||||
"Открыть в браузере",
|
||||
on_click=lambda e: open_captcha(e, True),
|
||||
),
|
||||
]
|
||||
),
|
||||
],
|
||||
spacing=8,
|
||||
)
|
||||
)
|
||||
|
||||
def render() -> None:
|
||||
profile: FreeTurnProfile = controller.freeturn
|
||||
threads, per_credential = profile.stream_settings
|
||||
content.controls = [
|
||||
section_title("Белые списки", "FreeTurn · отдельный runtime"),
|
||||
connection_card(controller.snapshot.state, controller.mode, "FreeTurn", slide),
|
||||
card(
|
||||
ft.Column(
|
||||
[
|
||||
ft.Text("Скорость", weight=ft.FontWeight.W_600),
|
||||
ft.Text(
|
||||
PRESET_TEXT[profile.preset],
|
||||
color=ft.Colors.ERROR if profile.preset == 4 else None,
|
||||
),
|
||||
ft.Slider(
|
||||
min=1,
|
||||
max=4,
|
||||
divisions=3,
|
||||
value=profile.preset,
|
||||
label="Уровень {value}",
|
||||
on_change=preset_change,
|
||||
),
|
||||
ft.Text(f"Потоки: {threads}/{per_credential}", color=ft.Colors.OUTLINE),
|
||||
ft.Switch(
|
||||
label="Повысить стабильность ценой нагрузки",
|
||||
value=profile.stability_over_load,
|
||||
disabled=profile.preset < 3,
|
||||
on_change=stability_change,
|
||||
),
|
||||
],
|
||||
spacing=8,
|
||||
)
|
||||
),
|
||||
captcha_settings(),
|
||||
ft.OutlinedButton("Остановить", icon=ft.Icons.STOP, on_click=stop),
|
||||
ft.Text(
|
||||
controller.snapshot.message or "",
|
||||
color=ft.Colors.ERROR if controller.snapshot.message else None,
|
||||
),
|
||||
]
|
||||
page.update()
|
||||
|
||||
registry.register("white-lists", "Белые списки", lambda: content)
|
||||
controller.add_listener(render)
|
||||
render()
|
||||
return content
|
||||
1
app/services/__init__.py
Normal file
1
app/services/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Application services."""
|
||||
26
app/services/billing.py
Normal file
26
app/services/billing.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from app.models import PaymentInfo
|
||||
|
||||
|
||||
def payment_banner(payment: PaymentInfo, today: date | None = None) -> str | None:
|
||||
today = today or date.today()
|
||||
due = payment.due_date(today.year, today.month)
|
||||
days = (due - today).days
|
||||
if days < 0:
|
||||
next_month = 1 if today.month == 12 else today.month + 1
|
||||
next_year = today.year + 1 if today.month == 12 else today.year
|
||||
due = payment.due_date(next_year, next_month)
|
||||
days = (due - today).days
|
||||
if days > 5:
|
||||
return None
|
||||
if days == 0:
|
||||
return f"Сегодня день оплаты · {payment.monthly_price_rub} ₽"
|
||||
return f"Оплата через {days} дн. · {due.strftime('%-d.%m')} · {payment.monthly_price_rub} ₽"
|
||||
|
||||
|
||||
def month_key(today: date | None = None) -> str:
|
||||
today = today or date.today()
|
||||
return f"{today.year:04d}-{today.month:02d}"
|
||||
168
app/services/controller.py
Normal file
168
app/services/controller.py
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
|
||||
from app.core.errors import EngineError, UnsupportedEngineError
|
||||
from app.core.factory import build_engine_manager
|
||||
from app.models import (
|
||||
CapabilitySet,
|
||||
EngineKind,
|
||||
FreeTurnProfile,
|
||||
ProxyAccess,
|
||||
ProxySettings,
|
||||
RunMode,
|
||||
RuntimeRequest,
|
||||
RuntimeSnapshot,
|
||||
SignBoxProfile,
|
||||
Strategy,
|
||||
VpnSettings,
|
||||
)
|
||||
|
||||
|
||||
class RuntimeController:
|
||||
def __init__(self, native_bridge=None) -> None:
|
||||
self.manager = build_engine_manager(native_bridge)
|
||||
self.signbox = SignBoxProfile(name="Основная", strategy=Strategy.BASIC)
|
||||
self.enhanced = SignBoxProfile(name="Усиленная", strategy=Strategy.ENHANCED)
|
||||
self.freeturn = FreeTurnProfile(name="Белые списки")
|
||||
self.proxy = ProxySettings()
|
||||
self.vpn = VpnSettings()
|
||||
self.mode = RunMode.VPN
|
||||
self.strategy = Strategy.BASIC
|
||||
self.engine_kind = EngineKind.SIGNBOX
|
||||
self.snapshot = RuntimeSnapshot()
|
||||
self._listeners: list[Callable[[], None]] = []
|
||||
|
||||
def add_listener(self, listener: Callable[[], None]) -> None:
|
||||
self._listeners.append(listener)
|
||||
|
||||
def capabilities(self) -> CapabilitySet:
|
||||
return self.manager.capabilities(self.engine_kind)
|
||||
|
||||
def _notify(self) -> None:
|
||||
for listener in tuple(self._listeners):
|
||||
listener()
|
||||
|
||||
@property
|
||||
def active_profile_id(self) -> str:
|
||||
if self.mode is RunMode.PROXY and self.strategy is Strategy.BASIC:
|
||||
return self.signbox.id
|
||||
return self.enhanced.id if self.strategy is Strategy.ENHANCED else self.signbox.id
|
||||
|
||||
async def connect(self) -> RuntimeSnapshot:
|
||||
if self.engine_kind is EngineKind.SIGNBOX:
|
||||
profile = self.enhanced if self.strategy is Strategy.ENHANCED else self.signbox
|
||||
request = RuntimeRequest(
|
||||
engine=EngineKind.SIGNBOX,
|
||||
mode=self.mode,
|
||||
profile_id=profile.id,
|
||||
proxy=self.proxy,
|
||||
vpn=self.vpn,
|
||||
engine_config=self._signbox_config(profile),
|
||||
)
|
||||
else:
|
||||
request = RuntimeRequest(
|
||||
engine=EngineKind.FREETURN,
|
||||
mode=RunMode.PROXY,
|
||||
profile_id=self.freeturn.id,
|
||||
proxy=self.proxy,
|
||||
vpn=self.vpn,
|
||||
engine_config=self._freeturn_config(),
|
||||
)
|
||||
self.snapshot = await self.manager.start(request)
|
||||
return self.snapshot
|
||||
|
||||
async def stop(self) -> RuntimeSnapshot:
|
||||
self.snapshot = await self.manager.stop()
|
||||
return self.snapshot
|
||||
|
||||
async def start_hotspot_share(self) -> RuntimeSnapshot:
|
||||
if self.engine_kind is not EngineKind.SIGNBOX:
|
||||
raise UnsupportedEngineError("Wi-Fi sharing is available for VPN strategy only")
|
||||
self.mode = RunMode.BOTH
|
||||
self.proxy.access = ProxyAccess.HOTSPOT
|
||||
return await self.connect()
|
||||
|
||||
async def refresh(self) -> RuntimeSnapshot:
|
||||
self.snapshot = await self.manager.get_state()
|
||||
if self.snapshot.engine is not None:
|
||||
self.engine_kind = self.snapshot.engine
|
||||
if self.snapshot.mode is not None:
|
||||
self.mode = self.snapshot.mode
|
||||
self._notify()
|
||||
return self.snapshot
|
||||
|
||||
def select_freeturn(self) -> None:
|
||||
self.engine_kind = EngineKind.FREETURN
|
||||
self.strategy = Strategy.BASIC
|
||||
self.mode = RunMode.PROXY
|
||||
|
||||
def select_strategy(self, strategy: Strategy) -> None:
|
||||
self.engine_kind = EngineKind.SIGNBOX
|
||||
self.strategy = strategy
|
||||
|
||||
def error_text(self, exc: Exception) -> str:
|
||||
if isinstance(exc, UnsupportedEngineError):
|
||||
return f"Недоступно: {exc}"
|
||||
if isinstance(exc, EngineError):
|
||||
return str(exc)
|
||||
return "Не удалось изменить состояние соединения"
|
||||
|
||||
def _signbox_config(self, profile: SignBoxProfile) -> dict[str, object]:
|
||||
if profile.raw_config:
|
||||
try:
|
||||
raw = json.loads(profile.raw_config)
|
||||
if isinstance(raw, dict):
|
||||
return raw
|
||||
except json.JSONDecodeError:
|
||||
return {"raw_config": profile.raw_config}
|
||||
return {
|
||||
**profile.config,
|
||||
"strategy": profile.strategy.value,
|
||||
"mtu": profile.mtu,
|
||||
"split_tunneling": profile.split_tunneling,
|
||||
}
|
||||
|
||||
def _freeturn_config(self) -> dict[str, object]:
|
||||
threads, per_credential = self.freeturn.stream_settings
|
||||
config = dict(self.freeturn.options)
|
||||
config_json = self._freeturn_config_json()
|
||||
if config_json:
|
||||
config["config_json"] = config_json
|
||||
config.update(
|
||||
{
|
||||
"key": self.freeturn.key,
|
||||
"threads": threads,
|
||||
"streams_per_cred": per_credential,
|
||||
}
|
||||
)
|
||||
return config
|
||||
|
||||
def _freeturn_config_json(self) -> str:
|
||||
raw = self.freeturn.options.get("config_json")
|
||||
if not isinstance(raw, str) or not raw.strip():
|
||||
raw = self.freeturn.key.strip()
|
||||
if raw.startswith("freeturn://"):
|
||||
encoded = raw.removeprefix("freeturn://")
|
||||
try:
|
||||
padding = "=" * (-len(encoded) % 4)
|
||||
raw = base64.urlsafe_b64decode(encoded + padding).decode("utf-8")
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
return ""
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return ""
|
||||
if not isinstance(parsed, dict):
|
||||
return ""
|
||||
parsed.setdefault("clientId", self.freeturn.id.replace("-", ""))
|
||||
threads, per_credential = self.freeturn.stream_settings
|
||||
turn = parsed.setdefault("turn", {})
|
||||
if isinstance(turn, dict):
|
||||
turn["n"] = threads
|
||||
vk = parsed.get("vk")
|
||||
if isinstance(vk, dict):
|
||||
vk["streamsPerCred"] = per_credential
|
||||
return json.dumps(parsed, separators=(",", ":"))
|
||||
22
app/services/settings_registry.py
Normal file
22
app/services/settings_registry.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SettingsSection:
|
||||
key: str
|
||||
title: str
|
||||
builder: Any
|
||||
|
||||
|
||||
class SettingsRegistry:
|
||||
def __init__(self) -> None:
|
||||
self._sections: list[SettingsSection] = []
|
||||
|
||||
def register(self, key: str, title: str, builder: Any) -> None:
|
||||
self._sections.append(SettingsSection(key, title, builder))
|
||||
|
||||
def sections(self) -> tuple[SettingsSection, ...]:
|
||||
return tuple(self._sections)
|
||||
32
app/services/state_store.py
Normal file
32
app/services/state_store.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
class StateStore:
|
||||
"""Small JSON store for UI preferences; native runtime state is not stored here."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
storage_root = os.getenv("FLET_APP_STORAGE_DATA")
|
||||
root = Path(
|
||||
os.getenv(
|
||||
"VPNAPP_STATE_DIR",
|
||||
storage_root or str(Path.home() / ".vpnapp"),
|
||||
)
|
||||
)
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
self.path = root / "ui-state.json"
|
||||
|
||||
def load(self) -> dict[str, Any]:
|
||||
try:
|
||||
return json.loads(self.path.read_text(encoding="utf-8"))
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
return {}
|
||||
|
||||
def save(self, value: dict[str, Any]) -> None:
|
||||
temporary = self.path.with_suffix(".tmp")
|
||||
temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
temporary.replace(self.path)
|
||||
96
app/services/subscription.py
Normal file
96
app/services/subscription.py
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from urllib.parse import parse_qs, urlsplit, urlunsplit
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClientVersion:
|
||||
major: int = 0
|
||||
minor: int = 1
|
||||
|
||||
def user_agent(self, device_id: str) -> str:
|
||||
return f"vpnapp/v{self.major}.{self.minor}/{device_id}"
|
||||
|
||||
|
||||
class SubscriptionError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class SubscriptionClient:
|
||||
"""Small device-side client for the backend contract.
|
||||
|
||||
Storage is injected deliberately: Android should provide a Keystore-backed
|
||||
implementation through the native bridge, while desktop can use an OS
|
||||
credential store. The client never logs the token or decrypted payload.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
load_secret: Callable[[str], str | None],
|
||||
save_secret: Callable[[str, str], None],
|
||||
version: ClientVersion = ClientVersion(),
|
||||
timeout: float = 15.0,
|
||||
) -> None:
|
||||
self._load_secret = load_secret
|
||||
self._save_secret = save_secret
|
||||
self.version = version
|
||||
self.timeout = timeout
|
||||
|
||||
def enroll_url(self, url: str) -> dict[str, str]:
|
||||
parsed = urlsplit(url)
|
||||
query = parse_qs(parsed.query)
|
||||
token = query.get("enroll", [""])[0]
|
||||
parts = [part for part in parsed.path.split("/") if part]
|
||||
if len(parts) != 2 or parts[0] != "d" or not parts[1] or not token:
|
||||
raise SubscriptionError("enrollment URL must look like /d/{device_id}?enroll=...")
|
||||
device_id = parts[1]
|
||||
subscription_url = urlunsplit((parsed.scheme, parsed.netloc, f"/d/{device_id}", "", ""))
|
||||
self._save_secret("device_id", device_id)
|
||||
self._save_secret("device_token", token)
|
||||
self._save_secret("subscription_url", subscription_url)
|
||||
return {"device_id": device_id, "subscription_url": subscription_url}
|
||||
|
||||
async def refresh(self) -> dict[str, Any]:
|
||||
url = self._load_secret("subscription_url")
|
||||
device_id = self._load_secret("device_id")
|
||||
token = self._load_secret("device_token")
|
||||
if not url or not device_id or not token:
|
||||
raise SubscriptionError("device is not enrolled")
|
||||
headers = {
|
||||
"Accept": "application/json",
|
||||
"User-Agent": self.version.user_agent(device_id),
|
||||
"X-Device-Token": token,
|
||||
}
|
||||
try:
|
||||
return await asyncio.to_thread(self._request, url, headers)
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = exc.read().decode("utf-8", errors="replace")[:200]
|
||||
raise SubscriptionError(f"subscription request failed ({exc.code}): {detail}") from exc
|
||||
except (urllib.error.URLError, TimeoutError) as exc:
|
||||
raise SubscriptionError("subscription server is unavailable") from exc
|
||||
|
||||
def apply(self, payload: dict[str, Any], controller: Any) -> None:
|
||||
"""Map the server payload into the split local profile models."""
|
||||
controller.signbox.config["vless_subscription_url"] = payload.get(
|
||||
"vless_subscription_url", ""
|
||||
)
|
||||
controller.enhanced.config["amnezia_account_name"] = payload.get(
|
||||
"amnezia_account_name", ""
|
||||
)
|
||||
controller.enhanced.config["amnezia_servers"] = payload.get("amnezia", {})
|
||||
controller.freeturn.key = str(payload.get("freeturn_key", ""))
|
||||
|
||||
def _request(self, url: str, headers: dict[str, str]) -> dict[str, Any]:
|
||||
request = urllib.request.Request(url, headers=headers, method="GET")
|
||||
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
||||
value = json.loads(response.read().decode("utf-8"))
|
||||
if not isinstance(value, dict):
|
||||
raise SubscriptionError("subscription response is not an object")
|
||||
return value
|
||||
24
app/theme.py
Normal file
24
app/theme.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import flet as ft
|
||||
|
||||
from app.models import Strategy
|
||||
|
||||
ACCENTS: dict[str, str] = {
|
||||
"freeturn": "#9E9E9E",
|
||||
Strategy.BASIC.value: "#8CC8FF",
|
||||
Strategy.ENHANCED.value: "#E85D5D",
|
||||
}
|
||||
|
||||
|
||||
def apply_accent(page: ft.Page, key: str) -> None:
|
||||
seed = ACCENTS.get(key, ACCENTS[Strategy.BASIC.value])
|
||||
page.theme = ft.Theme(color_scheme_seed=seed)
|
||||
page.dark_theme = ft.Theme(color_scheme_seed=seed)
|
||||
page.theme_mode = ft.ThemeMode.SYSTEM
|
||||
|
||||
|
||||
def card(content: Any) -> ft.Card:
|
||||
return ft.Card(content=ft.Container(content=content, padding=16))
|
||||
1
backend/__init__.py
Normal file
1
backend/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Small subscription backend."""
|
||||
57
backend/admin.html
Normal file
57
backend/admin.html
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>VPN App — управление</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark; font: 15px system-ui,sans-serif; }
|
||||
body { max-width: 1100px; margin: 0 auto; padding: 24px; background: #f7f7fb; color: #202124; }
|
||||
@media (prefers-color-scheme: dark) { body { background: #17181c; color: #eee; } }
|
||||
section { background: color-mix(in srgb, Canvas 90%, transparent); border-radius: 18px; padding: 18px; margin: 16px 0; box-shadow: 0 2px 10px #0001; }
|
||||
input,textarea,button { box-sizing: border-box; border: 1px solid #8885; border-radius: 10px; padding: 9px; margin: 4px; font: inherit; }
|
||||
input,textarea { width: min(100%, 420px); } textarea { min-height: 80px; vertical-align: top; }
|
||||
button { cursor: pointer; } table { width: 100%; border-collapse: collapse; } th,td { padding: 8px; border-bottom: 1px solid #8884; text-align: left; vertical-align: top; }
|
||||
code { word-break: break-all; } .muted { opacity: .68; } .danger { color: #c62828; } #status { white-space: pre-wrap; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>VPN App · управление</h1>
|
||||
<section>
|
||||
<label>Админ-токен <input id="token" type="password" autocomplete="off"></label>
|
||||
<button onclick="loadUsers()">Войти / обновить</button>
|
||||
<div id="status" class="muted"></div>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Новый пользователь</h2>
|
||||
<form onsubmit="createUser(event)">
|
||||
<input name="name" placeholder="Имя" required>
|
||||
<input name="price" type="number" min="0" placeholder="Руб./месяц" required>
|
||||
<input name="day" type="number" min="1" max="31" placeholder="День оплаты" required>
|
||||
<input name="vless" placeholder="VLESS subscription URL">
|
||||
<input name="account" placeholder="Скрытое имя аккаунта Amnezia">
|
||||
<input name="freeturn" placeholder="Ключ FreeTurn">
|
||||
<button>Создать</button>
|
||||
</form>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Пользователи</h2>
|
||||
<div id="users" class="muted">Введите токен и нажмите «Войти / обновить».</div>
|
||||
</section>
|
||||
<script>
|
||||
const token = document.querySelector('#token'); token.value = sessionStorage.getItem('vpnapp-admin-token') || '';
|
||||
const statusEl = document.querySelector('#status');
|
||||
function auth() { const value = token.value.trim(); sessionStorage.setItem('vpnapp-admin-token', value); return {'Authorization': 'Bearer ' + value, 'Content-Type':'application/json'}; }
|
||||
async function api(path, options={}) { options.headers = {...auth(), ...(options.headers||{})}; const r=await fetch(path,options); const data=await r.json().catch(()=>({})); if(!r.ok) throw new Error(data.detail||r.statusText); return data; }
|
||||
function esc(s) { return String(s??'').replace(/[&<>"']/g, c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
||||
async function loadUsers() { try { const data=await api('/admin/api/users'); renderUsers(data); statusEl.textContent='Готово'; } catch(e) { statusEl.textContent='Ошибка: '+e.message; } }
|
||||
async function createUser(e) { e.preventDefault(); const f=new FormData(e.target); try { await api('/admin/api/users',{method:'POST',body:JSON.stringify({name:f.get('name'),monthly_price_rub:Number(f.get('price')),payment_day:Number(f.get('day')),vless_subscription_url:f.get('vless'),amnezia_account_name:f.get('account'),freeturn_key:f.get('freeturn')})}); e.target.reset(); await loadUsers(); } catch(x) { statusEl.textContent='Ошибка: '+x.message; } }
|
||||
async function removeUser(id) { if(!confirm('Удалить пользователя и его устройства?')) return; await api('/admin/api/users/'+id,{method:'DELETE'}); loadUsers(); }
|
||||
async function addDevice(id) { try { const d=await api('/admin/api/users/'+id+'/devices',{method:'POST'}); alert('Сохраните enrollment URL:\n'+d.enrollment_url+'\n\nSubscription URL:\n'+d.subscription_url+'\n\nТокен показывается только сейчас.'); loadUsers(); } catch(e) { statusEl.textContent='Ошибка: '+e.message; } }
|
||||
async function removeDevice(id) { if(!confirm('Удалить устройство?')) return; await api('/admin/api/devices/'+id,{method:'DELETE'}); loadUsers(); }
|
||||
async function editServer(userId) { const name=prompt('Название сервера:'); if(!name) return; const config=prompt('Конфиг AmneziaWG 3.1 для сервера:'); if(!config) return; try { await api('/admin/api/users/'+userId+'/servers',{method:'POST',body:JSON.stringify({server_name:name,amnezia_config:config})}); await loadUsers(); } catch(e) { statusEl.textContent='Ошибка: '+e.message; } }
|
||||
async function removeServer(userId,name) { if(!confirm('Удалить конфиг сервера?')) return; try { await api('/admin/api/users/'+userId+'/servers/'+encodeURIComponent(name),{method:'DELETE'}); await loadUsers(); } catch(e) { statusEl.textContent='Ошибка: '+e.message; } }
|
||||
function renderUsers(users) { if(!users.length){document.querySelector('#users').textContent='Пользователей пока нет.';return;} document.querySelector('#users').innerHTML='<table><tr><th>Пользователь</th><th>Оплата</th><th>Устройства</th><th>Amnezia-серверы</th><th></th></tr>'+users.map(u=>`<tr><td><strong>${esc(u.name)}</strong><br><span class="muted">${esc(u.id)}</span></td><td>${u.monthly_price_rub} ₽ / мес<br>день ${u.payment_day}</td><td>${u.devices.map(d=>`<div><code>${esc(d.id)}</code><br><span class="muted">последнее: ${esc(d.last_seen_at||'—')}<br>UA: ${esc(d.last_user_agent||'—')}</span><br><button onclick="removeDevice('${esc(d.id)}')">Удалить устройство</button></div>`).join('<hr>')||'—'}<br><button onclick="addDevice('${esc(u.id)}')">Добавить устройство / ссылку</button></td><td>${u.server_configs.map(s=>`<div><code>${esc(s.server_name)}</code> <button onclick="removeServer('${esc(u.id)}','${esc(s.server_name)}')">×</button></div>`).join('')||'—'}<br><button onclick="editServer('${esc(u.id)}')">Добавить/изменить конфиг</button></td><td><button class="danger" onclick="removeUser('${esc(u.id)}')">Удалить пользователя</button></td></tr>`).join('')+'</table>'; }
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
30
backend/config.py
Normal file
30
backend/config.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Settings:
|
||||
admin_token: str
|
||||
secret_key: str
|
||||
base_url: str = "http://127.0.0.1:8080"
|
||||
database_path: str = "backend/vpnapp.db"
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> Settings:
|
||||
admin_token = os.getenv("VPNAPP_ADMIN_TOKEN", "")
|
||||
secret_key = os.getenv("VPNAPP_SECRET_KEY", "")
|
||||
if not admin_token:
|
||||
raise RuntimeError("VPNAPP_ADMIN_TOKEN must be set")
|
||||
if not secret_key:
|
||||
raise RuntimeError(
|
||||
"VPNAPP_SECRET_KEY must be set to a Fernet key; generate one with "
|
||||
"python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())'"
|
||||
)
|
||||
return cls(
|
||||
admin_token=admin_token,
|
||||
secret_key=secret_key,
|
||||
base_url=os.getenv("VPNAPP_BASE_URL", "http://127.0.0.1:8080").rstrip("/"),
|
||||
database_path=os.getenv("VPNAPP_DATABASE", "backend/vpnapp.db"),
|
||||
)
|
||||
183
backend/db.py
Normal file
183
backend/db.py
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import sqlite3
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from backend.security import SecretBox, token_hash, token_matches
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
return datetime.now(UTC).isoformat()
|
||||
|
||||
|
||||
class Database:
|
||||
def __init__(self, path: str, secrets_box: SecretBox) -> None:
|
||||
self.path = Path(path)
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.box = secrets_box
|
||||
self._init_schema()
|
||||
|
||||
def connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(self.path, timeout=10, isolation_level=None)
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA foreign_keys = ON")
|
||||
return connection
|
||||
|
||||
def _init_schema(self) -> None:
|
||||
with self.connect() as db:
|
||||
db.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
monthly_price_rub INTEGER NOT NULL CHECK(monthly_price_rub >= 0),
|
||||
payment_day INTEGER NOT NULL CHECK(payment_day BETWEEN 1 AND 31),
|
||||
vless_subscription_url TEXT NOT NULL DEFAULT '',
|
||||
amnezia_account_name TEXT NOT NULL DEFAULT '',
|
||||
freeturn_key TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS server_configs (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
server_name TEXT NOT NULL,
|
||||
amnezia_config TEXT NOT NULL,
|
||||
UNIQUE(user_id, server_name)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS devices (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash TEXT NOT NULL,
|
||||
bound_user_agent TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
last_seen_at TEXT,
|
||||
last_user_agent TEXT,
|
||||
revoked_at TEXT
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
def list_users(self) -> list[dict]:
|
||||
with self.connect() as db:
|
||||
users = [
|
||||
dict(row) for row in db.execute("SELECT * FROM users ORDER BY created_at DESC")
|
||||
]
|
||||
for user in users:
|
||||
user["devices"] = [
|
||||
dict(row)
|
||||
for row in db.execute(
|
||||
"SELECT id, created_at, last_seen_at, last_user_agent, bound_user_agent, revoked_at "
|
||||
"FROM devices WHERE user_id = ? ORDER BY created_at DESC",
|
||||
(user["id"],),
|
||||
)
|
||||
]
|
||||
user["server_configs"] = [
|
||||
dict(row)
|
||||
for row in db.execute(
|
||||
"SELECT id, server_name FROM server_configs WHERE user_id = ? ORDER BY server_name",
|
||||
(user["id"],),
|
||||
)
|
||||
]
|
||||
for user in users:
|
||||
user.pop("vless_subscription_url", None)
|
||||
user.pop("amnezia_account_name", None)
|
||||
user.pop("freeturn_key", None)
|
||||
return users
|
||||
|
||||
def create_user(self, data: dict) -> dict:
|
||||
user_id = str(uuid.uuid4())
|
||||
timestamp = now_iso()
|
||||
with self.connect() as db:
|
||||
db.execute(
|
||||
"INSERT INTO users VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
user_id,
|
||||
data["name"],
|
||||
data["monthly_price_rub"],
|
||||
data["payment_day"],
|
||||
self.box.seal(data.get("vless_subscription_url", "")),
|
||||
self.box.seal(data.get("amnezia_account_name", "")),
|
||||
self.box.seal(data.get("freeturn_key", "")),
|
||||
timestamp,
|
||||
timestamp,
|
||||
),
|
||||
)
|
||||
return {"id": user_id, "name": data["name"]}
|
||||
|
||||
def delete_user(self, user_id: str) -> bool:
|
||||
with self.connect() as db:
|
||||
cursor = db.execute("DELETE FROM users WHERE id = ?", (user_id,))
|
||||
return cursor.rowcount == 1
|
||||
|
||||
def create_device(self, user_id: str, base_url: str) -> dict:
|
||||
device_id = str(uuid.uuid4())
|
||||
token = secrets.token_urlsafe(32)
|
||||
with self.connect() as db:
|
||||
exists = db.execute("SELECT 1 FROM users WHERE id = ?", (user_id,)).fetchone()
|
||||
if exists is None:
|
||||
raise KeyError("user not found")
|
||||
db.execute(
|
||||
"INSERT INTO devices (id, user_id, token_hash, created_at) VALUES (?, ?, ?, ?)",
|
||||
(device_id, user_id, token_hash(token), now_iso()),
|
||||
)
|
||||
return {
|
||||
"device_id": device_id,
|
||||
"subscription_url": f"{base_url}/d/{device_id}",
|
||||
"enrollment_url": f"{base_url}/d/{device_id}?enroll={token}",
|
||||
"device_token": token,
|
||||
}
|
||||
|
||||
def delete_device(self, device_id: str) -> bool:
|
||||
with self.connect() as db:
|
||||
cursor = db.execute("DELETE FROM devices WHERE id = ?", (device_id,))
|
||||
return cursor.rowcount == 1
|
||||
|
||||
def bind_and_get_subscription(
|
||||
self, device_id: str, token: str, user_agent: str, user_agent_device: str
|
||||
) -> dict:
|
||||
now = now_iso()
|
||||
with self.connect() as db:
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
device = db.execute("SELECT * FROM devices WHERE id = ?", (device_id,)).fetchone()
|
||||
if device is None or device["revoked_at"]:
|
||||
raise KeyError("device not found or revoked")
|
||||
if not token_matches(token, device["token_hash"]):
|
||||
raise PermissionError("invalid device credential")
|
||||
if user_agent_device != device_id:
|
||||
raise ValueError("device id in user-agent does not match URL")
|
||||
bound = device["bound_user_agent"]
|
||||
if bound is not None and bound != user_agent:
|
||||
raise PermissionError("device is already bound to a different installation")
|
||||
if bound is None:
|
||||
db.execute(
|
||||
"UPDATE devices SET bound_user_agent = ? WHERE id = ?", (user_agent, device_id)
|
||||
)
|
||||
db.execute(
|
||||
"UPDATE devices SET last_seen_at = ?, last_user_agent = ? WHERE id = ?",
|
||||
(now, user_agent, device_id),
|
||||
)
|
||||
user = db.execute("SELECT * FROM users WHERE id = ?", (device["user_id"],)).fetchone()
|
||||
configs = db.execute(
|
||||
"SELECT server_name, amnezia_config FROM server_configs WHERE user_id = ?",
|
||||
(device["user_id"],),
|
||||
).fetchall()
|
||||
if user is None:
|
||||
raise KeyError("user not found")
|
||||
return {
|
||||
"user_id": user["id"],
|
||||
"name": user["name"],
|
||||
"monthly_price_rub": user["monthly_price_rub"],
|
||||
"payment_day": user["payment_day"],
|
||||
"vless_subscription_url": self.box.open(user["vless_subscription_url"]),
|
||||
"amnezia_account_name": self.box.open(user["amnezia_account_name"]),
|
||||
"amnezia": {
|
||||
row["server_name"]: self.box.open(row["amnezia_config"]) for row in configs
|
||||
},
|
||||
"freeturn_key": self.box.open(user["freeturn_key"]),
|
||||
"device_id": device_id,
|
||||
"updated_at": user["updated_at"],
|
||||
}
|
||||
116
backend/main.py
Normal file
116
backend/main.py
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request, status
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from backend.config import Settings
|
||||
from backend.db import Database
|
||||
from backend.schemas import ServerConfigCreate, UserCreate
|
||||
from backend.security import SecretBox, parse_user_agent
|
||||
|
||||
settings = Settings.from_env()
|
||||
database = Database(settings.database_path, SecretBox(settings.secret_key))
|
||||
app = FastAPI(title="VPN App backend", docs_url=None, redoc_url=None)
|
||||
ADMIN_HTML = (Path(__file__).parent / "admin.html").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def require_admin(authorization: str | None = Header(default=None)) -> None:
|
||||
expected = f"Bearer {settings.admin_token}"
|
||||
if authorization != expected:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid admin token")
|
||||
|
||||
|
||||
@app.get("/admin", response_class=HTMLResponse)
|
||||
def admin_page() -> str:
|
||||
return ADMIN_HTML
|
||||
|
||||
|
||||
@app.get("/admin/api/users", dependencies=[Depends(require_admin)])
|
||||
def list_users() -> list[dict]:
|
||||
return database.list_users()
|
||||
|
||||
|
||||
@app.post("/admin/api/users", dependencies=[Depends(require_admin)])
|
||||
def create_user(payload: UserCreate) -> dict:
|
||||
return database.create_user(payload.model_dump())
|
||||
|
||||
|
||||
@app.delete("/admin/api/users/{user_id}", dependencies=[Depends(require_admin)])
|
||||
def delete_user(user_id: str) -> dict:
|
||||
if not database.delete_user(user_id):
|
||||
raise HTTPException(status_code=404, detail="user not found")
|
||||
return {"deleted": user_id}
|
||||
|
||||
|
||||
@app.post("/admin/api/users/{user_id}/devices", dependencies=[Depends(require_admin)])
|
||||
def create_device(user_id: str) -> dict:
|
||||
try:
|
||||
return database.create_device(user_id, settings.base_url)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@app.delete("/admin/api/devices/{device_id}", dependencies=[Depends(require_admin)])
|
||||
def delete_device(device_id: str) -> dict:
|
||||
if not database.delete_device(device_id):
|
||||
raise HTTPException(status_code=404, detail="device not found")
|
||||
return {"deleted": device_id}
|
||||
|
||||
|
||||
@app.post("/admin/api/users/{user_id}/servers", dependencies=[Depends(require_admin)])
|
||||
def upsert_server(user_id: str, payload: ServerConfigCreate) -> dict:
|
||||
with database.connect() as db:
|
||||
if db.execute("SELECT 1 FROM users WHERE id = ?", (user_id,)).fetchone() is None:
|
||||
raise HTTPException(status_code=404, detail="user not found")
|
||||
db.execute(
|
||||
"INSERT INTO server_configs (id,user_id,server_name,amnezia_config) VALUES (?,?,?,?) "
|
||||
"ON CONFLICT(user_id,server_name) DO UPDATE SET amnezia_config=excluded.amnezia_config",
|
||||
(
|
||||
user_id + ":" + payload.server_name,
|
||||
user_id,
|
||||
payload.server_name,
|
||||
database.box.seal(payload.amnezia_config),
|
||||
),
|
||||
)
|
||||
return {"user_id": user_id, "server_name": payload.server_name}
|
||||
|
||||
|
||||
@app.delete(
|
||||
"/admin/api/users/{user_id}/servers/{server_name}",
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
def delete_server(user_id: str, server_name: str) -> dict:
|
||||
with database.connect() as db:
|
||||
cursor = db.execute(
|
||||
"DELETE FROM server_configs WHERE user_id = ? AND server_name = ?",
|
||||
(user_id, server_name),
|
||||
)
|
||||
if cursor.rowcount != 1:
|
||||
raise HTTPException(status_code=404, detail="server config not found")
|
||||
return {"deleted": server_name}
|
||||
|
||||
|
||||
@app.get("/d/{device_id}")
|
||||
def device_subscription(
|
||||
device_id: str,
|
||||
request: Request,
|
||||
enroll: str | None = Query(default=None),
|
||||
x_device_token: str | None = Header(default=None),
|
||||
) -> dict:
|
||||
token = x_device_token or enroll
|
||||
if not token:
|
||||
raise HTTPException(status_code=401, detail="X-Device-Token is required")
|
||||
user_agent = request.headers.get("user-agent", "")
|
||||
user_agent_device = parse_user_agent(user_agent)
|
||||
if user_agent_device is None:
|
||||
raise HTTPException(status_code=400, detail="invalid device user-agent")
|
||||
try:
|
||||
return database.bind_and_get_subscription(device_id, token, user_agent, user_agent_device)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
20
backend/pyproject.toml
Normal file
20
backend/pyproject.toml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
[project]
|
||||
name = "vpnapp-backend"
|
||||
version = "0.1.0"
|
||||
description = "Small operator and device-subscription backend for VPN App."
|
||||
requires-python = ">=3.12,<3.14"
|
||||
dependencies = [
|
||||
"cryptography>=45.0.0",
|
||||
"fastapi>=0.116.0",
|
||||
"pydantic>=2.11.0",
|
||||
"uvicorn[standard]>=0.35.0",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = ["pytest>=8.4.0"]
|
||||
|
||||
[tool.uv]
|
||||
package = false
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
pythonpath = [".."]
|
||||
21
backend/schemas.py
Normal file
21
backend/schemas.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=120)
|
||||
monthly_price_rub: int = Field(ge=0)
|
||||
payment_day: int = Field(ge=1, le=31)
|
||||
vless_subscription_url: str = ""
|
||||
amnezia_account_name: str = ""
|
||||
freeturn_key: str = ""
|
||||
|
||||
|
||||
class AdminToken(BaseModel):
|
||||
token: str
|
||||
|
||||
|
||||
class ServerConfigCreate(BaseModel):
|
||||
server_name: str = Field(min_length=1, max_length=120)
|
||||
amnezia_config: str = Field(min_length=1)
|
||||
33
backend/security.py
Normal file
33
backend/security.py
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import re
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
DEVICE_UA_RE = re.compile(r"^vpnapp/v(?P<major>\d+)\.(?P<minor>\d+)/(?P<device>[0-9a-fA-F-]{36})$")
|
||||
|
||||
|
||||
class SecretBox:
|
||||
def __init__(self, key: str) -> None:
|
||||
self._fernet = Fernet(key.encode("ascii"))
|
||||
|
||||
def seal(self, value: str) -> str:
|
||||
return self._fernet.encrypt(value.encode("utf-8")).decode("ascii")
|
||||
|
||||
def open(self, value: str) -> str:
|
||||
return self._fernet.decrypt(value.encode("ascii")).decode("utf-8")
|
||||
|
||||
|
||||
def token_hash(token: str) -> str:
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def token_matches(token: str, expected_hash: str) -> bool:
|
||||
return hmac.compare_digest(token_hash(token), expected_hash)
|
||||
|
||||
|
||||
def parse_user_agent(user_agent: str) -> str | None:
|
||||
match = DEVICE_UA_RE.fullmatch(user_agent.strip())
|
||||
return match.group("device") if match else None
|
||||
41
backend/tests/test_backend.py
Normal file
41
backend/tests/test_backend.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from backend.db import Database
|
||||
from backend.security import SecretBox, parse_user_agent, token_hash, token_matches
|
||||
|
||||
|
||||
def test_device_binding_and_secret_storage(tmp_path) -> None:
|
||||
box = SecretBox(Fernet.generate_key().decode())
|
||||
database = Database(str(tmp_path / "vpn.db"), box)
|
||||
user = database.create_user(
|
||||
{
|
||||
"name": "Alice",
|
||||
"monthly_price_rub": 500,
|
||||
"payment_day": 31,
|
||||
"vless_subscription_url": "https://secret/vless",
|
||||
"amnezia_account_name": "hidden",
|
||||
"freeturn_key": "private-key",
|
||||
}
|
||||
)
|
||||
device = database.create_device(user["id"], "https://sub.example.com")
|
||||
assert token_matches(device["device_token"], token_hash(device["device_token"]))
|
||||
ua = f"vpnapp/v1.2/{device['device_id']}"
|
||||
payload = database.bind_and_get_subscription(
|
||||
device["device_id"], device["device_token"], ua, device["device_id"]
|
||||
)
|
||||
assert payload["vless_subscription_url"] == "https://secret/vless"
|
||||
with pytest.raises(PermissionError):
|
||||
database.bind_and_get_subscription(
|
||||
device["device_id"], device["device_token"], "vpnapp/v1.2/other", device["device_id"]
|
||||
)
|
||||
listed = database.list_users()[0]
|
||||
assert "vless_subscription_url" not in listed
|
||||
assert box.seal("secret") != "secret"
|
||||
|
||||
|
||||
def test_user_agent_parser() -> None:
|
||||
assert parse_user_agent("vpnapp/v1.2/123e4567-e89b-12d3-a456-426614174000")
|
||||
assert parse_user_agent("bad-agent") is None
|
||||
518
backend/uv.lock
generated
Normal file
518
backend/uv.lock
generated
Normal file
|
|
@ -0,0 +1,518 @@
|
|||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.12, <3.14"
|
||||
|
||||
[[package]]
|
||||
name = "annotated-doc"
|
||||
version = "0.0.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "annotated-types"
|
||||
version = "0.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.15.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "idna" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cffi"
|
||||
version = "2.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" },
|
||||
]
|
||||
|
||||
[[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 = "cryptography"
|
||||
version = "50.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153, upload-time = "2026-08-25T19:44:03.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524, upload-time = "2026-08-25T19:44:11.96Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405, upload-time = "2026-08-25T19:44:20.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862, upload-time = "2026-08-25T19:45:07.163Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955, upload-time = "2026-08-25T19:45:16.193Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.141.1"
|
||||
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/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" },
|
||||
]
|
||||
|
||||
[[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 = "httptools"
|
||||
version = "0.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.20"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" },
|
||||
]
|
||||
|
||||
[[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 = "packaging"
|
||||
version = "26.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
|
||||
]
|
||||
|
||||
[[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 = "pycparser"
|
||||
version = "3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.13.5"
|
||||
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/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.46.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.21.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.1.1"
|
||||
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/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "1.2.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945, upload-time = "2026-08-16T16:54:54.067Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
version = "6.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "starlette"
|
||||
version = "1.6.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/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969, upload-time = "2026-08-08T18:27:57.512Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-inspection"
|
||||
version = "0.4.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uvicorn"
|
||||
version = "0.53.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5d/ad/04bbb797c84fc1f26cb171f7394716f4865ffb8d8c5e1eef42565c2dfa6b/uvicorn-0.53.0.tar.gz", hash = "sha256:a9356f0cb89b3b8621529c5d5eebd69bfe154f4c3f68b4cf2de47e45fa855c2e", size = 110881, upload-time = "2026-09-14T07:44:23.815Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/76/18/0eea75741ee812e9f598b687619ce2454f6c3a1c5cd21ea990ec6bd26f45/uvicorn-0.53.0-py3-none-any.whl", hash = "sha256:e8dca71ec86dce5f04e333f0d56cdedf942446e6643b9cea1af0d6d3a02cb03e", size = 87081, upload-time = "2026-09-14T07:44:22.179Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
standard = [
|
||||
{ name = "httptools" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
|
||||
{ name = "watchfiles" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uvloop"
|
||||
version = "0.22.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vpnapp-backend"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "pytest" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "cryptography", specifier = ">=45.0.0" },
|
||||
{ name = "fastapi", specifier = ">=0.116.0" },
|
||||
{ name = "pydantic", specifier = ">=2.11.0" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.35.0" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [{ name = "pytest", specifier = ">=8.4.0" }]
|
||||
|
||||
[[package]]
|
||||
name = "watchfiles"
|
||||
version = "1.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b3/68/e6aa0b77d217b31f8f486ec0cdfe5e00e6e38dc0be657e7d85819b9faf0a/watchfiles-1.3.0.tar.gz", hash = "sha256:99aee4a07847c06820765fd7b1b49ceac4f3f711ccb7d104655a33231de1c207", size = 108119, upload-time = "2026-09-21T09:08:55.664Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/68/fa/c0b840d5d8bafe640925408ce11095948c8945bb61be1e67d0ab629b872a/watchfiles-1.3.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:000b9688fc8133037a8b075c8ebf98f32844ff8964dda61e85c1db547dafc441", size = 399703, upload-time = "2026-09-21T09:08:13.518Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/8a/894799b485fe9473ad10422a0d9668e53fb78e3a2b5cc6061159572844ad/watchfiles-1.3.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:bbc1198edfdc90fda0600f825aa94150f428dfcbf8138746f55998e0e660d64c", size = 397647, upload-time = "2026-09-21T09:08:15.16Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/61/f277cd5ead05f5d37b98b41fbdd0c943ee0c04d49738f62e902cb291cf43/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4af9c464d410e8c44b58ddf1cbec7c3a02c1cb2c6c80df9c74659ba2fbda0ab5", size = 454994, upload-time = "2026-09-21T09:08:16.587Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/66/ea4c01382975e53b1ac1b0a0ea1b353416b8127fb6e04f5c1c313119aab6/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb94e40b9a0db19636b5e3fed0a9803452ecc7381df13b385272abb779ee83ca", size = 460105, upload-time = "2026-09-21T09:08:17.961Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/c3/1a501b597817096c9831c255f6270c124ce0e2faeb74124be24dcc93b86d/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7398930a2b76b9dc67a4e6b8bfc7baf30946892a86f417a3920c07bdd6febc30", size = 493761, upload-time = "2026-09-21T09:08:19.327Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/16/77352d05e152c7786a016e9f1decdb4507e8aa5bcf3343c72142a0413d14/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:025b108f2d5cc2799cb941f79380df3e8b22bfb589c218e801767fe78d598c95", size = 577500, upload-time = "2026-09-21T09:08:20.723Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/fe/81001c55b24466fe9911135e50269fd3a29f43e446627a92adfab54553b6/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:be9ef3cd403d756a304a0a08a112163c63da7ec72c8c9d083c98818291ff8f29", size = 469209, upload-time = "2026-09-21T09:08:22.346Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/cf/e3ad894ed5909a7c44b0707e89bc25d19a3d78b3a7aec846119443b6ae56/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5768b49e426fd5b550b012c866db347cdf15c398ef98dc557b6e6b72fa74cd1", size = 458353, upload-time = "2026-09-21T09:08:23.7Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/f5/2f106829e6e2bc91ad74614730f6df484202b9a4d20d927dbf2f08d37d44/watchfiles-1.3.0-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:d36c72fcdc08f143d87316a4c4c3b064e340da2c7383d39803950740ed418935", size = 468707, upload-time = "2026-09-21T09:08:25.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/37/2741ab776b978669c1f3bba1e573f1a184f727ab8b4d3a6d9c61090800ed/watchfiles-1.3.0-cp310-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:f0c9865240e065a2247f4b5248528ae3d01e0e8ae071462c250c884dc16e3ee3", size = 632510, upload-time = "2026-09-21T09:08:26.532Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/64/db4a4684275fe9b696ee5118c194705c1423b3d42181c3f8ee70194d5c73/watchfiles-1.3.0-cp310-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:380f513d26cc2e598266b88d66456e07d67ef4be8f0f435a1154d9c44b43d509", size = 661723, upload-time = "2026-09-21T09:08:28.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/80/95ddb24f6a2f595c031c24432ab7b8aa7d6d1ada0c6d63a552166e06e8d1/watchfiles-1.3.0-cp310-abi3-win32.whl", hash = "sha256:509d9f74d2bec5c1f4cd868ddcfe0c9601f7ea53066dfcb19d8ede742c4de661", size = 277761, upload-time = "2026-09-21T09:08:29.335Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/a1/1c4a1b3c8030cd03206232612be8a6e8799093490c220b0fcb935d02b75a/watchfiles-1.3.0-cp310-abi3-win_amd64.whl", hash = "sha256:1acabde19b67e673274e89a04d613b7fb1f122e2c4b8ec9a581e59c149264b61", size = 290643, upload-time = "2026-09-21T09:08:30.45Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/e0/d2db577c632d26d08078c4390dd870b8555c4e558df25d22270f0166c71f/watchfiles-1.3.0-cp310-abi3-win_arm64.whl", hash = "sha256:d978cc1dd7ba44f5590d7de74f7a9c8be7262d9e469c2c0efb8bbfc2574321dc", size = 285510, upload-time = "2026-09-21T09:08:31.561Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "websockets"
|
||||
version = "17.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/18/72/fba934cb3dff7a85d811820efffcd141ddd52b5a2a01637f64551373ff4d/websockets-17.1.tar.gz", hash = "sha256:acfea4c20bf54384883ea33b1240fc1db4f52e190823a4e2b334bc3e8bfca96a", size = 187520, upload-time = "2026-08-26T17:25:33.063Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/0d/098f23c4c858e5de9459ffc554fa07d5493fbcfca7f040b5800cf1cecc35/websockets-17.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:76dd004f59115087c7b700474cb18f01325e37250032e19396c08ae41448e4b3", size = 217015, upload-time = "2026-08-26T14:55:45.194Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/86/bc1317b1a4d8c4688e2a7e564b5e004dab44c2534d7ca05de6ae9a863fca/websockets-17.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:581fa678ef46f4277cc8491312468e582f8ad609dbab907ba6096a08c6a0ff98", size = 214692, upload-time = "2026-08-26T14:55:46.366Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/e7/df821761772beaa48c211ee0e234930b35c1473778470773823f56d3911b/websockets-17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:87f0d5e77548b0c40c8464cdb6108792e7e53f487c6400028a4ec28a8afbe5ab", size = 214959, upload-time = "2026-08-26T14:55:47.885Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/92/c3fb72f11764812fc648bf3838d224972427b348e8b3989d9e0a9df87da3/websockets-17.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:882af300d2c6a092b93767d5de03c7bb56dfb06314140c8e872d3f48e09f7b74", size = 224278, upload-time = "2026-08-26T14:55:49.241Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/05/9f82d090c8d2d861604147ef6dfb938a90b039f9358d5193f1df62558593/websockets-17.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0c863507ada5805517ca6dff1c524dcd42942efe6304dacf06700878398d21a6", size = 224557, upload-time = "2026-08-26T14:55:50.348Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/50/5cbf677b865290fe36819ff00615826e7edc1df38786f770123ff39a933d/websockets-17.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d41ef69d5416fbc1d98cf96c37be6192d10fd101c3e0f8b3ddc36e09432b3c08", size = 225791, upload-time = "2026-08-26T14:55:51.75Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/1c/eb8a032285243381b09a221ae384c972d5000453ad136add4d1595cec798/websockets-17.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5aefe78e6a3077fe22b5e64b04666a85a3eb8b934d40e8595a693adcbceb6f11", size = 228574, upload-time = "2026-08-26T14:55:52.922Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/85/413736251cb3ac04ce84cbd90e893d9a36a9698d4820b323aff3aa187e50/websockets-17.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f64e001bb7fa89b9f32cfa600bf8e9ac8ca26759d9b92ae01453ee303d9cd7b4", size = 226428, upload-time = "2026-08-26T14:55:54.263Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/2b/a08bcc7fa1ca81a10f84ba32b6e6edd73a913f4b0c2640eed1fd626efacd/websockets-17.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:677014a073bcb1fbaa7e21144786864f16c08f856d66834f611eceb9006cbab8", size = 225184, upload-time = "2026-08-26T14:55:55.943Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/8a/3bd2d0cf6b148c8c866d5d9fdcde30c04bfd81fdfac86813e69377eb4448/websockets-17.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0de501b7f2db11e83739ac20e2d33d46da4604b829f506c24be80e7def069391", size = 222430, upload-time = "2026-08-26T14:55:57.103Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/c9/8e891ae342668735eabbbc669895e15195e4b45f24a4beeb58af76f414c7/websockets-17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f62114a54117e4948a1e414e89521f7fe1e3c2f83f2a571a06a4fc6718b0900a", size = 225227, upload-time = "2026-08-26T14:55:58.375Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/6f/c816f332dca11425e9bda7c07f7573eb5c5f8a735849d02b0d81e8ee20fa/websockets-17.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:eec113a5b41d124ef42ff56b0d74a6da3fd986400038eab9e58ee42a4024e837", size = 223831, upload-time = "2026-08-26T14:55:59.664Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/67/5e91d5308ce24fc1ec74f56536c12f4888bad45ff5ea50f3180f8c518c57/websockets-17.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5f051f8030a51815dc00e24bd2e5f1435af095c1cc111d747ac6e2a3620d7641", size = 224600, upload-time = "2026-08-26T14:56:00.873Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/96/faa298ecf2570d35b0eb37caddf4992178d907e108ed74bfffb6bc092c29/websockets-17.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:655a8e28010f09fd6fa317e857afab3af7647f33e41dee88fa421e92086d1090", size = 225707, upload-time = "2026-08-26T14:56:02.001Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/12/5710d2482ca5061c1eec5eb46f6313837c760d4115b1795c85b6c08be4e3/websockets-17.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dc2b79afc074d2f3e64b26539350f697fe1b85ea1c49ea24eb588f247b053ce1", size = 223263, upload-time = "2026-08-26T14:56:03.092Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/47/0c30f4eebfd1d93fae779d268f678d48847fb98516f5200849574eee8820/websockets-17.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e4bd7eacb87d8cf3ed70d6392c770a0d92441f05d7d2a3efafb5bc171d5e3067", size = 224244, upload-time = "2026-08-26T14:56:04.321Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/33/46c256195a1255079ae23d1b1267b2e1843dc5f46a67f973cdf2a3523dff/websockets-17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ccbf3f4a9890d50b3a08ee04029fde30a03bfdeffaa19977628bf17251764e60", size = 224520, upload-time = "2026-08-26T14:56:05.521Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/9a/aef0792731df4352e5f417369b532b3325fe434765ca90c193f594ae1e67/websockets-17.1-cp312-cp312-win32.whl", hash = "sha256:7e724f843fa6a0614aece65a7c73e51d0f4412ca41dccac13c3caf98e69536bb", size = 217485, upload-time = "2026-08-26T14:56:06.715Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/23/493ecfdaf32898e5ea24dc900e33e5e317f9662d5d9ab2d44b2e111b4e1c/websockets-17.1-cp312-cp312-win_amd64.whl", hash = "sha256:617243e19a0992095956f406ee9cd3bc4ba92862d83cb1d83bb59ce574412bec", size = 217786, upload-time = "2026-08-26T14:56:08.055Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/3d/91954e2f7876f74ce1213e9b92c65a63b559cc4b942a931ebeb351cd9932/websockets-17.1-cp312-cp312-win_arm64.whl", hash = "sha256:9f4a08ff7cb68c27b18e09223cc6304e01d0f82d5a240d251266dfd2e6e44729", size = 217711, upload-time = "2026-08-26T14:56:09.267Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/31/5f6450a7879f4f063ef08897cc385ea3ce3f1fe17f08b11e3fd959abdf27/websockets-17.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2a0162a6372110a5601cb5c9fd826635cedf69f3e110c545dd19774e040b970e", size = 217006, upload-time = "2026-08-26T14:56:10.509Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/2a/c1b006fc861695d2aa4e35327b842015ce1d98cf8f99241829b3d6460bfc/websockets-17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:829dba1bc049779de9b332088c1a6a9858e96bd67e50b6b644a95e02b67836bc", size = 214690, upload-time = "2026-08-26T14:56:11.681Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/69/66e5b7d01445e0eeb1d4ab419c30315f2c90cf7a8a8cd4ecc47f894dba54/websockets-17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fd8f47dbf2e8adb15c847215f83436de3fdb120b51fdae0fbbdf69fd97a3ad80", size = 214947, upload-time = "2026-08-26T14:56:12.923Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/ce/033cafe2d2538562efa876b9149a2c7a0f7787870a4b1bb6e28adc9ceb6b/websockets-17.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9f4c0377a83e163a303514fdfab501dbe379bdc13e5b9312a91d112658b29dce", size = 224329, upload-time = "2026-08-26T14:56:14.212Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/c7/e1c2e8a67f6cc0aa43abe0046fb3b7a020980649e6a843751dc7ce9eb170/websockets-17.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c3241d684a76eaaef8b2dc789afde4343cd3aad55ea81e4e8ab3605b529bae51", size = 224611, upload-time = "2026-08-26T14:56:15.702Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/de/07c6d48eb3d2069709410c851e7de10ab83d752c4bd09862899627c2729b/websockets-17.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e5f5c7a893507d0e83a80b88aefd6522f7e882cd53f9722c6f23f5a020c9557c", size = 225848, upload-time = "2026-08-26T14:56:16.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/dd/3c68572d20509648cc2fb6f50ccf3deeb4b87270f2c8966e99476e278ea3/websockets-17.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:00bf34b64501e3477e81fc281532ff3cbf4da26633c10b63979d5085d46602d3", size = 227290, upload-time = "2026-08-26T14:56:18.204Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/4a/8f6651c8a22093539c9215af0c5bbf217b87b382c99d2112039b92d593c2/websockets-17.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ce0305b702b20d1e1d60a9aaace6bc89970e1753565543f310d549eab22c2435", size = 226476, upload-time = "2026-08-26T14:56:19.459Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/be/f6fc33cea86b1127fd1297b18c107e81580ab55a73a39f9a934441ef321f/websockets-17.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29176d8b429cfa0fa443c473878d37a5c06cfd0cb36b71ba4314accc71e05906", size = 225233, upload-time = "2026-08-26T14:56:20.939Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/83/65edaf05f7c9b1dea82f4d252fdc37706a84571646f06119a27b0a16fe19/websockets-17.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3709a1ab30b4b922027d22f68d2b61a0656a91680ac894a537624e6be7dd7f7c", size = 222488, upload-time = "2026-08-26T14:56:22.208Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/42/d1169c2f7f1f0032b0d4b0c00f0711a070cd7c735de37bfeb876bc0f9606/websockets-17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:43bd0c1ceb924d67f5c1a5254d8361dd9d94246e6331a726064dfa2917880780", size = 225295, upload-time = "2026-08-26T14:56:23.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/f4/64e2a386c3899b917c2933225c9b47887874229d159797f3bf1a11c20d51/websockets-17.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:1fce0f43e0d41422e0b2cad6561e1970df22f212f4c7e884967df7cf591b031c", size = 223891, upload-time = "2026-08-26T14:56:24.647Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/b3/dfb5c482f7e310a3432fdbb045ddfe6d34114680e89a233d4ff900a32961/websockets-17.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4031152769179ab8dcdeafc7b0e58052a49117560a28671700b47b2c7b717aad", size = 224661, upload-time = "2026-08-26T14:56:26.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/cf/94865130a336029f46412adc127c4fbe380f46172b90ce251369e35c4302/websockets-17.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a06f3b5085176763182449559e20391d7ce616a8972a9f7a33deda87ea6d4f3c", size = 225766, upload-time = "2026-08-26T14:56:27.455Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/34/eb8c658f86dfe562ed49a887a27424bfe9e618c26ea6f865b093d075d3a6/websockets-17.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:77b37cceca17291897c3c73bd30a7c7c7909593554b5da574ec852af83c1742a", size = 223323, upload-time = "2026-08-26T14:56:28.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/7e/2629609652ece5ca0c7ac235927dd4511b08131e3a5d53439b798fddf002/websockets-17.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d8e83333385cac6030a5167fd18bf96cc6c58b914c308e683f05b0cf94bc8dd0", size = 224276, upload-time = "2026-08-26T14:56:29.991Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/6b/8525737fe840b38e5f40956c198fb586a4fac1e07144d41a5b949b989cf8/websockets-17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:073c5c3f7e127041fa9d34a9e29ceefee8c3cafbd267ed2927318f425144380d", size = 224558, upload-time = "2026-08-26T14:56:31.184Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/ab/3a958c6cbcf74b118f601c20a80ac8bd5e8dfec0bcf7345116feaeefb121/websockets-17.1-cp313-cp313-win32.whl", hash = "sha256:2afb58c7ba48b329d56769f8dfd89f394efe587b65ef806bae810a484d6d3608", size = 217475, upload-time = "2026-08-26T14:56:32.431Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/36/fb521f0f2994c25509651f169efe5582dddd8713d57a0757ba87859372ef/websockets-17.1-cp313-cp313-win_amd64.whl", hash = "sha256:0340bbef6bfbe16da888b3983d666a4db4954ac3253c38f13bc7aba0c7db5a2f", size = 217784, upload-time = "2026-08-26T14:56:33.608Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/92/9b8419584681a12a7534b746dfb2737c466efe2455483e2fbf8b941a04ec/websockets-17.1-cp313-cp313-win_arm64.whl", hash = "sha256:7a72efa3bf4fa3a6669a54420a472ad056da3973d827f10e3a536da463f926c2", size = 217715, upload-time = "2026-08-26T14:56:34.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/63/23572870e01836a98346075b9e17a8bc24a6ddd9800a3204ceee58677f3c/websockets-17.1-py3-none-any.whl", hash = "sha256:f221081107b8c48184d99f7019604486376e7ef826037e70aad6b02540732c23", size = 211134, upload-time = "2026-08-26T17:25:31.397Z" },
|
||||
]
|
||||
29
docs/android-manual-tests.md
Normal file
29
docs/android-manual-tests.md
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# Android manual/integration tests
|
||||
|
||||
These require a real Android device; an emulator cannot validate all hotspot/VPN ROM
|
||||
behaviour.
|
||||
|
||||
1. Grant VPN consent, connect/disconnect Basic VLESS and Enhanced AmneziaWG 3.1.
|
||||
2. Lock the screen and kill/reopen the Flet Activity. Native state must remain visible;
|
||||
a second engine/service must not start.
|
||||
3. Force-stop/restart and verify the documented recovery limitation: Android does not
|
||||
guarantee recovery after an explicit force-stop.
|
||||
4. Kill the engine and verify `ERROR`/`UNSUPPORTED`, no orphan process, and no fallback.
|
||||
5. `DIRECT`/`VOID` are currently a model-only gate and must remain visibly unavailable;
|
||||
after native routing is implemented, `DIRECT` must use the Android API and `VOID`
|
||||
must drop in the native data path, never `addDisallowedApplication`.
|
||||
6. After an adapter reports real listener addresses, start LocalOnlyHotspot, check actual
|
||||
SSID/password/interface IP, and confirm listeners bind only to that IP. Verify SOCKS5
|
||||
and HTTP CONNECT from a laptop on that network. Current adapters intentionally do not
|
||||
claim this path.
|
||||
7. Stop sharing and verify listeners close and the reservation is released.
|
||||
8. Run Both and verify phone TUN plus laptop proxy use the same backend only when the
|
||||
capability says `supports_both`.
|
||||
9. Switch SignBox strategies and then FreeTurn. Conflicting runtimes must stop before
|
||||
another one starts.
|
||||
10. Deny VPN/hotspot permissions and verify an explicit error, never `CONNECTED`.
|
||||
11. Trigger FreeTurn CAPTCHA. Verify in-app WebView where available, browser fallback,
|
||||
and the “always open in browser” setting.
|
||||
12. Run an aggressive VPN-detection app. Expected VOID result is limited: local VPN/TUN
|
||||
signs may remain visible, but probes/telemetry entering our VPN data path must be
|
||||
dropped. No stealth guarantee is made.
|
||||
102
docs/architecture.md
Normal file
102
docs/architecture.md
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
# Архитектура
|
||||
|
||||
## Scope
|
||||
|
||||
Текущий продукт — Android + desktop. iOS исключён. macOS не собирается в первом MVP;
|
||||
если платформа вернётся, на ней разрешён только proxy-only до отдельного native review.
|
||||
|
||||
В приложении две верхние вкладки: `VPN` и `Белые списки`.
|
||||
|
||||
- `VPN` использует `SignBox` как один движок для стратегий `Основная` (VLESS) и
|
||||
`Усиленная` (AmneziaWG 3.1).
|
||||
- `Белые списки` использует отдельный `FreeTurn` runtime. FreeTurn не является
|
||||
VPN-стратегией SignBox.
|
||||
|
||||
## Boundaries
|
||||
|
||||
```text
|
||||
Flet/Python
|
||||
models, profiles, UI, theme, payment banner, settings registry
|
||||
|
|
||||
| versioned idempotent commands
|
||||
v
|
||||
Flet service extension / MethodChannel
|
||||
|
|
||||
v
|
||||
Android native services (separate lifetime from Activity)
|
||||
VpnRuntimeService (VpnService + foreground)
|
||||
ProxyRuntimeService (foreground + proxy/hotspot)
|
||||
|
|
||||
+-----+--------------------+
|
||||
| SignBox/libbox adapter | FreeTurn mobile adapter
|
||||
| TUN/proxy/stats | Start/StartTunnel/Stop/Captcha
|
||||
```
|
||||
|
||||
Python does not start an Android subprocess and does not own VPN lifetime. On desktop,
|
||||
the SignBox adapter may own a configured executable with `asyncio` process management;
|
||||
FreeTurn desktop is explicitly unsupported.
|
||||
|
||||
Android state is authoritative in native `SharedPreferences`. Reopening the Flet UI must
|
||||
call `get_runtime_state`; missing Python state never implies that a service is stopped.
|
||||
The current native bridge already has this contract, but the engine AAR API still must
|
||||
be pinned before the adapter can report `RUNNING`.
|
||||
|
||||
## Engine abstraction
|
||||
|
||||
`app/core/interface.py` defines the common lifecycle. `BackendRegistry` and
|
||||
`EngineManager` select adapters by registry and enforce one active engine. Capabilities
|
||||
are checked before start. Unsupported combinations raise `UnsupportedEngineError` and
|
||||
are rendered as `Недоступно`, not as connected.
|
||||
|
||||
Internals remain different:
|
||||
|
||||
- Once the exact native artifact is supplied, SignBox can expose VLESS and AmneziaWG
|
||||
packet/TUN plus local SOCKS/HTTP. The current adapter deliberately reports
|
||||
`UNSUPPORTED` until that API is pinned and implemented.
|
||||
- FreeTurn uses its own mobile binding and event/CAPTCHA lifecycle. Its `Start` API is
|
||||
not a generic SOCKS5/HTTP listener; standard sharing remains unsupported until a
|
||||
separately implemented listener reports actual bound addresses.
|
||||
- `VOID` is an Android VPN data-path policy and must be enforced before an engine or
|
||||
direct fallback. It must never be implemented as `addDisallowedApplication`, because
|
||||
Android then sends that app directly.
|
||||
|
||||
## Modes and sharing
|
||||
|
||||
Desktop is proxy-only in the first release. Android supports `TUN`, `Proxy`, and
|
||||
`Both` only when the selected native artifact advertises the matching capability.
|
||||
|
||||
Proxy defaults to `127.0.0.1`; Wi-Fi sharing uses `LocalOnlyHotspot`, discovers its
|
||||
actual interface address, and binds listeners only after the hotspot callback. It never
|
||||
assumes `192.168.43.1`, does not use `0.0.0.0` as a user default, and does not use
|
||||
system tethering/NAT. SOCKS5 and HTTP CONNECT share one optional credential pair.
|
||||
|
||||
## Backend and devices
|
||||
|
||||
The backend has separate operator and device authentication:
|
||||
|
||||
- operator CRUD uses one configured admin Bearer token;
|
||||
- each device gets a UUID4 and a separate random credential, stored server-side only
|
||||
as SHA-256 hash;
|
||||
- enrollment is one-time via an admin-created URL; the first request must use
|
||||
`vpnapp/vMAJOR.MINOR/DEVICE_UUID` and binds that User-Agent;
|
||||
- subsequent requests use `X-Device-Token` over HTTPS and the same bound User-Agent;
|
||||
- UUID/User-Agent alone are not secrets and cannot authenticate.
|
||||
|
||||
Secrets in SQLite are Fernet-encrypted with `VPNAPP_SECRET_KEY`. The admin HTML never
|
||||
lists secret fields after creation. A stronger future option is a hardware-backed key
|
||||
and signed requests with replay protection.
|
||||
|
||||
## Theme and UI
|
||||
|
||||
The accent is derived from active top-level/strategy context: neutral gray for FreeTurn,
|
||||
light blue for Basic, red for Enhanced, while `ThemeMode.SYSTEM` respects the device
|
||||
light/dark mode. Screens register their settings sections in `SettingsRegistry`, so the
|
||||
settings screen can grow without a universal nullable profile.
|
||||
|
||||
## Deliberate non-goals
|
||||
|
||||
- iOS, Personal Hotspot automation and iOS per-app routing.
|
||||
- Android application routing (`DIRECT`/`VOID`) until native UID ownership and the
|
||||
fail-closed packet drop path are implemented and tested on real devices.
|
||||
- bundled native cores without pinned source, license, ABI and API evidence.
|
||||
- management/debug listeners exposed by SignBox.
|
||||
26
docs/backend.md
Normal file
26
docs/backend.md
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# Backend contract
|
||||
|
||||
The admin UI is a single `backend/admin.html` file. It sends the configured admin
|
||||
Bearer token in memory/session storage and calls only `/admin/api/*` endpoints.
|
||||
|
||||
## Device flow
|
||||
|
||||
1. Operator creates a user and device in the page.
|
||||
2. Backend returns a one-time `enrollment_url` and a stable `subscription_url`.
|
||||
3. The app makes the first request with `X-Device-Token: <random token>` and
|
||||
`User-Agent: vpnapp/vMAJOR.MINOR/DEVICE_UUID`.
|
||||
4. Backend checks URL UUID, credential, and User-Agent UUID atomically, records the
|
||||
User-Agent, and returns the user's decrypted-for-transport config payload.
|
||||
5. Polling every few hours uses the same device token and bound User-Agent. A different
|
||||
installation cannot reuse the same device binding unless the token is copied; UUID
|
||||
and User-Agent alone never authenticate.
|
||||
|
||||
The device endpoint must be deployed behind TLS. The admin token is not accepted on
|
||||
`/d/{device_id}`. Server configs and user secrets are Fernet-encrypted at rest with
|
||||
`VPNAPP_SECRET_KEY`; only sanitized fields are shown in the admin list.
|
||||
|
||||
The reusable client-side request code is in `app/services/subscription.py`. It accepts
|
||||
an injected secret store, emits the required User-Agent, performs one-time enrollment,
|
||||
refreshes the device subscription asynchronously, and maps the response into the
|
||||
separate VLESS/Amnezia/FreeTurn profile models. Android should connect its injected
|
||||
store to Keystore-backed native storage before shipping credentials.
|
||||
70
docs/building.md
Normal file
70
docs/building.md
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
# Сборка и запуск
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
uv run pytest
|
||||
uv run flet run app/main.py
|
||||
```
|
||||
|
||||
`uv` уже был установлен в рабочем окружении. Flet current build creates a Flutter
|
||||
shell and packages the Python app; the native Android bridge is registered as a local
|
||||
Flet extension from `native/android/flet_vpn_bridge`.
|
||||
|
||||
## Backend
|
||||
|
||||
Generate a Fernet key and configure the operator token:
|
||||
|
||||
```bash
|
||||
export VPNAPP_ADMIN_TOKEN="$(python -c 'import secrets; print(secrets.token_urlsafe(32))')"
|
||||
export VPNAPP_SECRET_KEY="$(uv run --project backend python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())')"
|
||||
export VPNAPP_BASE_URL=http://127.0.0.1:8080
|
||||
./scripts/run-backend.sh
|
||||
```
|
||||
|
||||
Open `http://127.0.0.1:8080/admin`. The page is one self-contained HTML file with no
|
||||
frontend build. It asks for the configured token, lists users/devices, creates/deletes
|
||||
them and generates enrollment/subscription links. Deploy behind HTTPS and do not put
|
||||
the admin token in a public URL.
|
||||
|
||||
## Android
|
||||
|
||||
The Flet shell can be built without native core artifacts; runtime connections then
|
||||
return `UNSUPPORTED` honestly. A release build can enforce both artifacts:
|
||||
|
||||
```bash
|
||||
./scripts/fetch-engine-artifacts.sh
|
||||
REQUIRE_NATIVE_ARTIFACTS=1 ./scripts/build-android.sh
|
||||
```
|
||||
|
||||
For a native-bridge/permissions smoke APK without cores, run
|
||||
`./scripts/build-android.sh`. The script targets `arm64-v8a`.
|
||||
|
||||
Build prerequisites are current JDK/Android SDK/Flutter as required by Flet. Use a
|
||||
device/emulator with API 26+ for LocalOnlyHotspot; API 33+ needs runtime
|
||||
`NEARBY_WIFI_DEVICES`. Android VPN consent must be granted interactively by the user.
|
||||
|
||||
FreeTurn AAR:
|
||||
|
||||
```bash
|
||||
git clone --depth 1 --branch <pinned-ref> https://github.com/samosvalishe/free-turn-proxy.git /tmp/free-turn-proxy
|
||||
cd /tmp/free-turn-proxy
|
||||
task build:android
|
||||
cp dist/freeturn.aar /path/to/repo/native/android/libs/
|
||||
```
|
||||
|
||||
For SignBox, use the exact artifact that exposes the API expected by
|
||||
`native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/android/.../EngineAdapter.kt`. A generic sing-box
|
||||
`libbox.aar` is not silently assumed to be the user's private SignBox fork. Record its
|
||||
commit, build tags, ABIs, license and SHA-256 in `THIRD_PARTY_NOTICES.md`, then replace
|
||||
the adapter with calls from that artifact and add a native integration test.
|
||||
|
||||
## Desktop
|
||||
|
||||
```bash
|
||||
SIGNBOX_BIN=/absolute/path/to/sign-box ./scripts/build-desktop.sh linux
|
||||
SIGNBOX_BIN=/absolute/path/to/sign-box.exe ./scripts/build-desktop.sh windows
|
||||
```
|
||||
|
||||
Desktop SignBox proxy mode is process based. System proxy changes are not performed.
|
||||
38
docs/implementation-status.md
Normal file
38
docs/implementation-status.md
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# Текущий статус MVP
|
||||
|
||||
## Проверено локально
|
||||
|
||||
- Flet web shell starts; Linux desktop bundle builds.
|
||||
- ARM64 Android APK builds with current Flet/Gradle toolchain.
|
||||
- FreeTurn `freeturn.aar` from the pinned upstream commit is embedded as
|
||||
`classes.jar + jniLibs`; Kotlin reflection calls the documented mobile API,
|
||||
installs `EventSink`/`Protector`, and keeps its state in the foreground service.
|
||||
- FastAPI backend, one-file admin UI, encrypted-at-rest secrets, device enrollment,
|
||||
User-Agent binding and subscription response pass tests and a local HTTP smoke test.
|
||||
- Python lifecycle, conflict rules, serialization, billing date, proxy settings,
|
||||
enrollment URL and port-selection tests pass.
|
||||
|
||||
## Deliberately unavailable until the missing native work is supplied
|
||||
|
||||
- SignBox VLESS/AmneziaWG 3.1: the exact private AAR/API was not supplied. The
|
||||
native adapter and Android VPN modes report `UNSUPPORTED`; no generic sing-box
|
||||
artifact is guessed.
|
||||
- Android SOCKS5/HTTP listener, Wi-Fi QR sharing and `Both`: the current adapter
|
||||
does not report actual bound listeners, so hotspot sharing fails closed instead
|
||||
of showing ports that do not exist.
|
||||
- `DIRECT`/`VOID` application routing: models and UI boundary exist, but UID
|
||||
ownership, `addDisallowedApplication` for DIRECT and native fail-closed packet
|
||||
dropping for VOID are not implemented.
|
||||
- Backend sync is a reusable client contract with injected secret storage; wiring
|
||||
it to Android Keystore, an enrollment screen and automatic profile refresh remains.
|
||||
|
||||
These are functional blockers, not cosmetic polish. A successful APK build is only a
|
||||
build/integration milestone; it is not evidence that the missing data path works.
|
||||
|
||||
## Device acceptance still required
|
||||
|
||||
Use `docs/android-manual-tests.md` on a physical Android API 26+ device after the
|
||||
SignBox artifact and listener implementation are added. In particular test VPN
|
||||
consent, screen lock/UI death, service conflict, backend crash, hotspot permissions,
|
||||
actual interface address, laptop SOCKS/HTTP traffic and the aggressive VPN-detection
|
||||
VOID case.
|
||||
50
docs/upstream-research.md
Normal file
50
docs/upstream-research.md
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
# Upstream/API research snapshot
|
||||
|
||||
Checked 2026-09-22.
|
||||
|
||||
## Flet
|
||||
|
||||
Current Flet publishing docs describe a generated Flutter shell and support
|
||||
`[tool.flet.app] path/module`, local developer packages and Flutter user extensions.
|
||||
The bridge uses a Flet `Service` control and a Dart `FletService`, then a standard
|
||||
Flutter `MethodChannel` for the Android plugin. It is intentionally not a Python
|
||||
subprocess bridge.
|
||||
|
||||
## Android
|
||||
|
||||
Current Android docs require `VpnService.prepare()`, `protect()` for upstream sockets,
|
||||
`Builder.establish()`, and a foreground service on API 26+. Android 14+ foreground
|
||||
services require a declared type; this project uses the documented `specialUse` type and
|
||||
an explanatory property. Android has one active VPN service per user/profile.
|
||||
|
||||
`Builder.addDisallowedApplication()` is valid for `DIRECT`, but it makes traffic bypass
|
||||
the VPN and therefore cannot implement `VOID`. `VOID` needs a UID-aware native packet
|
||||
path and a fail-closed drop policy; the Python model exists now, while enforcement is a
|
||||
device-tested native milestone.
|
||||
|
||||
`LocalOnlyHotspot` is the public API for an app-created local network. Android 13+
|
||||
requires `NEARBY_WIFI_DEVICES`; older targets use location permission. The reservation
|
||||
returns SSID/password but the interface address must be discovered at runtime. The
|
||||
implementation does not assume a fixed `192.168.43.1` address and does not enable
|
||||
system tethering.
|
||||
|
||||
## FreeTurn
|
||||
|
||||
The current `samosvalishe/free-turn-proxy` mobile README documents the gomobile API:
|
||||
`Start`, `StartTunnel`, `StartDirectTunnel`, `Restart`, `Stop`, `GetState`,
|
||||
`TunnelStats`, `ParseTunnelConfig`, `SetProtect`, `SetEventSink`, `ValidateConfig` and
|
||||
`OnCaptcha`. The Android AAR is built with the upstream `task build:android` target.
|
||||
The Android reference app is GPL-3.0 and is used only as lifecycle/API evidence.
|
||||
|
||||
## SignBox / sing-box
|
||||
|
||||
The exact public name `signbox` and a public artifact matching the user's VLESS +
|
||||
AmneziaWG 3.1 claim were not found. Upstream sing-box exposes `libbox.aar` through
|
||||
gomobile, but its package/API and build tags must be checked against the actual SignBox
|
||||
artifact before compiling calls. The repo therefore has a real adapter boundary and a
|
||||
hard `UNSUPPORTED` result rather than a fabricated success.
|
||||
|
||||
## Removed scope
|
||||
|
||||
iOS and NetworkExtension are not implemented. macOS is omitted from the first build;
|
||||
desktop target is Linux/Windows proxy-only.
|
||||
40
native/android/README.md
Normal file
40
native/android/README.md
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# Android native layer
|
||||
|
||||
This directory is a Flet Flutter service extension. The Python `NativeBridge` control
|
||||
sends small versioned commands over the extension; Kotlin owns the actual Android
|
||||
lifecycle.
|
||||
|
||||
The native layer is deliberately split from the Flet Activity:
|
||||
|
||||
```text
|
||||
Python/Flet -> FletService -> MethodChannel -> Kotlin bridge
|
||||
-> VpnRuntimeService (VpnService)
|
||||
-> ProxyRuntimeService (foreground service)
|
||||
-> FreeTurn/libbox adapters
|
||||
```
|
||||
|
||||
`VpnRuntimeService` calls `VpnService.prepare()` through the bridge, starts foreground
|
||||
immediately, configures a predictable `10.0.0.1/24` TUN, and persists state in
|
||||
`SharedPreferences`. The current adapter refuses to report success unless the expected
|
||||
native engine classes are bundled. This avoids a fake VPN that installs a black-hole
|
||||
interface.
|
||||
|
||||
## Native artifacts
|
||||
|
||||
Put reviewable, pinned artifacts in `libs/` locally:
|
||||
|
||||
- `freeturn.aar` from the FreeTurn `mobile` package.
|
||||
- `libbox.aar` or the exact private SignBox AAR with VLESS + AmneziaWG 3.1 support.
|
||||
|
||||
The files are git-ignored. Record version, commit and SHA-256 in
|
||||
`THIRD_PARTY_NOTICES.md` before distributing an APK. `scripts/fetch-engine-artifacts.sh`
|
||||
prints the upstream build commands and checks for the files; it does not silently
|
||||
download an unpinned binary.
|
||||
|
||||
`scripts/build-android.sh` extracts each present AAR's `classes.jar` and `jni/*` into
|
||||
the packaged Flutter plugin. This is required because Gradle does not allow a direct
|
||||
local `.aar` dependency while producing the plugin AAR. A missing artifact does not
|
||||
make a smoke APK pretend to work: the corresponding adapter returns `UNSUPPORTED`.
|
||||
|
||||
The first native milestone still needs device testing for VPN consent, foreground
|
||||
service timing, packet forwarding, hotspot interface discovery, and engine ABI/API.
|
||||
19
native/android/flet_vpn_bridge/pyproject.toml
Normal file
19
native/android/flet_vpn_bridge/pyproject.toml
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
[project]
|
||||
name = "vpn-native-bridge"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = ["flet>=0.86.0"]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=75"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.flet.extensions]
|
||||
vpn_native_bridge = "vpn_native_bridge.Extension"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
include = ["vpn_native_bridge*", "flutter.vpn_native_bridge*"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
"flutter.vpn_native_bridge" = ["**/*"]
|
||||
1
native/android/flet_vpn_bridge/src/flutter/__init__.py
Normal file
1
native/android/flet_vpn_bridge/src/flutter/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Namespace package containing the bundled Flutter extension."""
|
||||
|
|
@ -0,0 +1 @@
|
|||
"""Packaged Flutter extension files for Flet build discovery."""
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
group 'su.snw.vpn_native_bridge'
|
||||
version '0.1.0'
|
||||
|
||||
buildscript {
|
||||
ext.kotlin_version = '2.1.20'
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath "com.android.tools.build:gradle:8.9.1"
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
apply plugin: 'com.android.library'
|
||||
apply plugin: 'kotlin-android'
|
||||
|
||||
android {
|
||||
namespace 'su.snw.vpn_native_bridge'
|
||||
compileSdk 35
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = '1.8'
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
minSdk 26
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
|
||||
// The build script extracts reviewed AARs into a local JAR plus jniLibs.
|
||||
// Gradle cannot embed a direct local .aar dependency while producing this
|
||||
// plugin AAR, while a local JAR and native library are packaged correctly.
|
||||
if (file("libs/freeturn-classes.jar").exists()) {
|
||||
implementation files("libs/freeturn-classes.jar")
|
||||
}
|
||||
if (file("libs/libbox-classes.jar").exists()) {
|
||||
implementation files("libs/libbox-classes.jar")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="32" />
|
||||
<application>
|
||||
<service
|
||||
android:name=".VpnRuntimeService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse"
|
||||
android:permission="android.permission.BIND_VPN_SERVICE">
|
||||
<intent-filter>
|
||||
<action android:name="android.net.VpnService" />
|
||||
</intent-filter>
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="Maintains the user's active VPN tunnel." />
|
||||
</service>
|
||||
<service
|
||||
android:name=".ProxyRuntimeService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="Maintains an explicitly requested local proxy." />
|
||||
</service>
|
||||
</application>
|
||||
</manifest>
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
package su.snw.vpn_native_bridge
|
||||
|
||||
import android.content.Context
|
||||
import org.json.JSONObject
|
||||
import java.lang.reflect.InvocationTargetException
|
||||
import java.lang.reflect.Proxy
|
||||
|
||||
internal data class AdapterResult(
|
||||
val supported: Boolean,
|
||||
val message: String? = null,
|
||||
val proxyAddresses: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
internal interface EngineAdapter {
|
||||
fun start(context: Context, requestJson: String, tunFd: Int?): AdapterResult
|
||||
fun stop(): AdapterResult
|
||||
}
|
||||
|
||||
/**
|
||||
* The libbox API is intentionally isolated here. The exact AAR/signbox package is a
|
||||
* release input, not an invented compile-time API. Until it is pinned, refusing to
|
||||
* start is safer than showing a connected TUN with no packet engine behind it.
|
||||
*/
|
||||
internal class SignBoxAdapter : EngineAdapter {
|
||||
override fun start(context: Context, requestJson: String, tunFd: Int?): AdapterResult {
|
||||
return AdapterResult(false, "SignBox/libbox AAR is not bundled or its API is not pinned")
|
||||
}
|
||||
|
||||
override fun stop(): AdapterResult = AdapterResult(true)
|
||||
}
|
||||
|
||||
internal class FreeTurnAdapter : EngineAdapter {
|
||||
private var eventSink: Any? = null
|
||||
private var protector: Any? = null
|
||||
|
||||
override fun start(context: Context, requestJson: String, tunFd: Int?): AdapterResult {
|
||||
return try {
|
||||
val config = JSONObject(requestJson).optJSONObject("engine_config")
|
||||
val configJson = config?.optString("config_json", "") ?: ""
|
||||
if (configJson.isBlank()) {
|
||||
return AdapterResult(false, "FreeTurn config_json is not configured")
|
||||
}
|
||||
val mobile = Class.forName("com.freeturn.core.mobile.Mobile")
|
||||
mobile.getMethod("setStateDir", String::class.java)
|
||||
.invoke(null, context.filesDir.resolve("freeturn-state").absolutePath)
|
||||
installEventSink(mobile)
|
||||
if (tunFd != null) installProtector(mobile, context)
|
||||
if (tunFd == null) {
|
||||
mobile.getMethod("start", String::class.java).invoke(null, configJson)
|
||||
} else {
|
||||
mobile.getMethod("startTunnel", String::class.java, Long::class.javaPrimitiveType)
|
||||
.invoke(null, configJson, tunFd.toLong())
|
||||
}
|
||||
AdapterResult(true)
|
||||
} catch (error: ClassNotFoundException) {
|
||||
AdapterResult(false, "FreeTurn AAR is not bundled; add the pinned freeturn.aar")
|
||||
} catch (error: InvocationTargetException) {
|
||||
AdapterResult(false, "FreeTurn rejected the configuration: ${error.targetException.message}")
|
||||
} catch (error: ReflectiveOperationException) {
|
||||
AdapterResult(false, "FreeTurn mobile API is incompatible: ${error.message}")
|
||||
} catch (error: RuntimeException) {
|
||||
AdapterResult(false, "FreeTurn startup failed: ${error.message}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun stop(): AdapterResult {
|
||||
return try {
|
||||
val mobile = Class.forName("com.freeturn.core.mobile.Mobile")
|
||||
mobile.getMethod("setEventSink", Class.forName("com.freeturn.core.mobile.EventSink"))
|
||||
.invoke(null, null)
|
||||
mobile.getMethod("setProtect", Class.forName("com.freeturn.core.mobile.Protector"))
|
||||
.invoke(null, null)
|
||||
mobile.getMethod("stop").invoke(null)
|
||||
eventSink = null
|
||||
protector = null
|
||||
AdapterResult(true)
|
||||
} catch (_: ClassNotFoundException) {
|
||||
AdapterResult(true)
|
||||
} catch (error: ReflectiveOperationException) {
|
||||
AdapterResult(false, "FreeTurn stop failed: ${error.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun installEventSink(mobile: Class<*>) {
|
||||
val sinkType = Class.forName("com.freeturn.core.mobile.EventSink")
|
||||
eventSink = Proxy.newProxyInstance(
|
||||
sinkType.classLoader,
|
||||
arrayOf(sinkType),
|
||||
) { _, method, args ->
|
||||
if (method.name == "onCaptcha" && args != null) {
|
||||
val url = args.getOrNull(0)?.toString()?.ifBlank { null }
|
||||
if (url != null) RuntimeStore.lastInstance?.updatePayload("captcha_url", url)
|
||||
} else if (method.name == "onState" && args != null) {
|
||||
val state = args.getOrNull(0)?.toString() ?: "connecting"
|
||||
val message = args.getOrNull(3)?.toString()?.ifBlank { null }
|
||||
RuntimeStore.lastInstance?.write(
|
||||
if (state == "connected") "running" else if (state == "error") "error" else "starting",
|
||||
message,
|
||||
)
|
||||
}
|
||||
null
|
||||
}
|
||||
mobile.getMethod("setEventSink", sinkType).invoke(null, eventSink)
|
||||
}
|
||||
|
||||
private fun installProtector(mobile: Class<*>, context: Context) {
|
||||
val protectorType = Class.forName("com.freeturn.core.mobile.Protector")
|
||||
protector = Proxy.newProxyInstance(
|
||||
protectorType.classLoader,
|
||||
arrayOf(protectorType),
|
||||
) { _, method, args ->
|
||||
if (method.name == "protect" && args != null) {
|
||||
val fd = (args.firstOrNull() as? Number)?.toInt() ?: return@newProxyInstance false
|
||||
return@newProxyInstance (context as android.net.VpnService).protect(fd)
|
||||
}
|
||||
null
|
||||
}
|
||||
mobile.getMethod("setProtect", protectorType).invoke(null, protector)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
package su.snw.vpn_native_bridge
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.wifi.WifiManager
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import java.net.Inet4Address
|
||||
import java.net.NetworkInterface
|
||||
|
||||
internal class HotspotController(private val context: Context) {
|
||||
private val wifi = context.getSystemService(WifiManager::class.java)
|
||||
private var reservation: WifiManager.LocalOnlyHotspotReservation? = null
|
||||
|
||||
fun start(onReady: (HotspotInfo) -> Unit, onError: (String) -> Unit) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||
context.checkSelfPermission(Manifest.permission.NEARBY_WIFI_DEVICES) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
onError("NEARBY_WIFI_DEVICES permission is required")
|
||||
return
|
||||
}
|
||||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2 &&
|
||||
context.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
onError("ACCESS_FINE_LOCATION permission is required on Android 12 and older")
|
||||
return
|
||||
}
|
||||
wifi.startLocalOnlyHotspot(object : WifiManager.LocalOnlyHotspotCallback() {
|
||||
override fun onStarted(value: WifiManager.LocalOnlyHotspotReservation) {
|
||||
reservation = value
|
||||
val configuration = value.wifiConfiguration
|
||||
if (configuration == null) {
|
||||
value.close()
|
||||
reservation = null
|
||||
onError("LocalOnlyHotspot started without configuration")
|
||||
return
|
||||
}
|
||||
val address = findHotspotAddress()
|
||||
if (address == null) {
|
||||
value.close()
|
||||
reservation = null
|
||||
onError("LocalOnlyHotspot started but its interface address was not discovered")
|
||||
return
|
||||
}
|
||||
onReady(HotspotInfo(configuration.SSID, configuration.preSharedKey, address))
|
||||
}
|
||||
|
||||
override fun onStopped() {
|
||||
reservation = null
|
||||
onError("LocalOnlyHotspot stopped")
|
||||
}
|
||||
|
||||
override fun onFailed(reason: Int) {
|
||||
onError("LocalOnlyHotspot failed: $reason")
|
||||
}
|
||||
}, Handler(Looper.getMainLooper()))
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
reservation?.close()
|
||||
reservation = null
|
||||
}
|
||||
|
||||
private fun findHotspotAddress(): String? {
|
||||
val interfaces = NetworkInterface.getNetworkInterfaces() ?: return null
|
||||
while (interfaces.hasMoreElements()) {
|
||||
val networkInterface = interfaces.nextElement()
|
||||
val addresses = networkInterface.inetAddresses
|
||||
while (addresses.hasMoreElements()) {
|
||||
val address = addresses.nextElement()
|
||||
if (address is Inet4Address && !address.isLoopbackAddress && address.isSiteLocalAddress) {
|
||||
return address.hostAddress
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
internal data class HotspotInfo(val ssid: String?, val password: String?, val address: String)
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
package su.snw.vpn_native_bridge
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
class ProxyRuntimeService : Service() {
|
||||
private lateinit var store: RuntimeStore
|
||||
private var hotspot: HotspotController? = null
|
||||
private var adapter: EngineAdapter? = null
|
||||
private var intentRequest: String = "{}"
|
||||
private val worker = Executors.newSingleThreadExecutor()
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
store = RuntimeStore(this)
|
||||
store.setComponent("proxy")
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
getSystemService(NotificationManager::class.java).createNotificationChannel(
|
||||
NotificationChannel(CHANNEL, "VPN App proxy", NotificationManager.IMPORTANCE_LOW)
|
||||
)
|
||||
}
|
||||
startForeground(NOTIFICATION_ID, notification())
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
intentRequest = intent?.getStringExtra(VpnRuntimeService.EXTRA_REQUEST) ?: "{}"
|
||||
when (intent?.action) {
|
||||
ACTION_START -> worker.execute { startProxy(intentRequest) }
|
||||
ACTION_START_HOTSPOT -> startHotspot()
|
||||
ACTION_STOP -> {
|
||||
worker.execute {
|
||||
adapter?.stop()
|
||||
adapter = null
|
||||
hotspot?.stop()
|
||||
hotspot = null
|
||||
store.write("stopped")
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onDestroy() {
|
||||
adapter?.stop()
|
||||
adapter = null
|
||||
hotspot?.stop()
|
||||
hotspot = null
|
||||
worker.shutdownNow()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun startProxy(requestJson: String) {
|
||||
val request = org.json.JSONObject(requestJson)
|
||||
val engineName = request.optString("engine", "signbox")
|
||||
if (!store.canStart(engineName)) {
|
||||
store.write("error", "another engine is already active", request = request)
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
store.write("starting", request = request)
|
||||
adapter = if (engineName == "freeturn") FreeTurnAdapter() else SignBoxAdapter()
|
||||
val result = adapter!!.start(this, requestJson, null)
|
||||
if (!result.supported) {
|
||||
store.write("unsupported", result.message, request = request)
|
||||
adapter = null
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
store.write("running", request = request)
|
||||
}
|
||||
|
||||
private fun startHotspot() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
store.write("unsupported", "LocalOnlyHotspot requires Android API 26+")
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
val request = org.json.JSONObject(intentRequest)
|
||||
val engineName = request.optString("engine", "signbox")
|
||||
if (!store.canStart(engineName)) {
|
||||
store.write("error", "another engine is already active", request = request)
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
hotspot = HotspotController(this)
|
||||
hotspot!!.start(
|
||||
onReady = { info ->
|
||||
worker.execute {
|
||||
val request = org.json.JSONObject(intentRequest)
|
||||
store.write("starting", request = request)
|
||||
val proxy = request.optJSONObject("proxy")
|
||||
proxy?.put("bind_address", info.address)
|
||||
val engineName = request.optString("engine", "signbox")
|
||||
adapter = if (engineName == "freeturn") FreeTurnAdapter() else SignBoxAdapter()
|
||||
val result = adapter!!.start(this, request.toString(), null)
|
||||
if (!result.supported || result.proxyAddresses.isEmpty()) {
|
||||
store.write(
|
||||
"unsupported",
|
||||
result.message ?: "native adapter did not expose a proxy listener",
|
||||
)
|
||||
adapter = null
|
||||
hotspot?.stop()
|
||||
hotspot = null
|
||||
stopSelf()
|
||||
return@execute
|
||||
}
|
||||
val payload = org.json.JSONObject()
|
||||
.put("ssid", info.ssid)
|
||||
.put("password", info.password)
|
||||
.put("address", info.address)
|
||||
val addresses = org.json.JSONArray()
|
||||
result.proxyAddresses.forEach(addresses::put)
|
||||
payload.put("addresses", addresses)
|
||||
store.write("running", payload = payload, request = request)
|
||||
}
|
||||
},
|
||||
onError = { message ->
|
||||
store.write("error", message)
|
||||
hotspot?.stop()
|
||||
hotspot = null
|
||||
stopSelf()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun notification(): Notification {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
Notification.Builder(this, CHANNEL).setContentTitle("VPN App").setContentText("Proxy service").setSmallIcon(android.R.drawable.stat_sys_warning).build()
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
Notification.Builder(this).setContentTitle("VPN App").setContentText("Proxy service").setSmallIcon(android.R.drawable.stat_sys_warning).build()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACTION_START = "su.snw.vpnapp.START_PROXY"
|
||||
const val ACTION_START_HOTSPOT = "su.snw.vpnapp.START_HOTSPOT_PROXY"
|
||||
const val ACTION_STOP = "su.snw.vpnapp.STOP_PROXY"
|
||||
private const val CHANNEL = "vpnapp-proxy"
|
||||
private const val NOTIFICATION_ID = 7102
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
package su.snw.vpn_native_bridge
|
||||
|
||||
import android.content.Context
|
||||
import org.json.JSONObject
|
||||
|
||||
internal class RuntimeStore(context: Context) {
|
||||
private val prefs = context.getSharedPreferences("vpnapp-runtime", Context.MODE_PRIVATE)
|
||||
private var componentName: String = "runtime"
|
||||
|
||||
init {
|
||||
lastInstance = this
|
||||
}
|
||||
|
||||
fun setComponent(name: String) {
|
||||
componentName = name
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun canStart(engine: String): Boolean {
|
||||
val current = prefs.getString("snapshot", null)?.let { JSONObject(it) }
|
||||
val currentEngine = current?.optString("engine", null)
|
||||
val currentState = current?.optString("state", "stopped")
|
||||
if (currentState !in setOf("starting", "running", "stopping")) return true
|
||||
if (currentEngine != null && currentEngine != engine) return false
|
||||
val component = prefs.getString("component_$componentName", null)
|
||||
?.let { JSONObject(it) }
|
||||
return component?.optString("state", "stopped") !in setOf("starting", "running", "stopping")
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun write(
|
||||
state: String,
|
||||
message: String? = null,
|
||||
payload: JSONObject? = null,
|
||||
request: JSONObject? = null,
|
||||
) {
|
||||
val value = JSONObject()
|
||||
.put("state", state)
|
||||
.put("message", message)
|
||||
.put("payload", payload ?: JSONObject())
|
||||
val metadata = request ?: prefs.getString("snapshot", null)?.let { JSONObject(it) }
|
||||
val editor = prefs.edit().putString("component_$componentName", value.toString())
|
||||
if (metadata != null) {
|
||||
listOf("engine", "mode", "profile_id").forEach { key ->
|
||||
if (metadata.has(key)) editor.putString("meta_$key", metadata.optString(key))
|
||||
}
|
||||
}
|
||||
editor.apply()
|
||||
prefs.edit().putString("snapshot", aggregate().toString()).apply()
|
||||
}
|
||||
|
||||
fun updatePayload(key: String, value: String) {
|
||||
val current = prefs.getString("component_$componentName", null)
|
||||
?.let { JSONObject(it) } ?: JSONObject()
|
||||
val payload = current.optJSONObject("payload") ?: JSONObject()
|
||||
payload.put(key, value)
|
||||
current.put("payload", payload)
|
||||
prefs.edit().putString("component_$componentName", current.toString()).apply()
|
||||
prefs.edit().putString("snapshot", aggregate().toString()).apply()
|
||||
}
|
||||
|
||||
fun read(): Map<String, Any?> {
|
||||
val raw = prefs.getString("snapshot", null) ?: return mapOf("state" to "stopped")
|
||||
val value = JSONObject(raw)
|
||||
return mapOf(
|
||||
"state" to value.optString("state", "stopped"),
|
||||
"message" to value.optString("message", null),
|
||||
"engine" to value.optString("engine", null),
|
||||
"mode" to value.optString("mode", null),
|
||||
"profile_id" to value.optString("profile_id", null),
|
||||
"payload" to value.optJSONObject("payload")?.toString(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun aggregate(): JSONObject {
|
||||
val vpn = component("vpn")
|
||||
val proxy = component("proxy")
|
||||
val components = listOf(vpn, proxy)
|
||||
val active = components.filter {
|
||||
it.optString("state", "stopped") in setOf("starting", "running", "stopping")
|
||||
}
|
||||
val state = when {
|
||||
active.any { it.optString("state") == "starting" } -> "starting"
|
||||
active.isNotEmpty() -> "running"
|
||||
components.any { it.optString("state") == "error" } -> "error"
|
||||
components.any { it.optString("state") == "unsupported" } -> "unsupported"
|
||||
else -> "stopped"
|
||||
}
|
||||
val message = components.firstOrNull {
|
||||
it.optString("state") in setOf("error", "unsupported")
|
||||
}?.optString("message", null)
|
||||
val payload = proxy.optJSONObject("payload") ?: JSONObject()
|
||||
return JSONObject()
|
||||
.put("state", state)
|
||||
.put("message", message)
|
||||
.put("engine", prefs.getString("meta_engine", null))
|
||||
.put("mode", prefs.getString("meta_mode", null))
|
||||
.put("profile_id", prefs.getString("meta_profile_id", null))
|
||||
.put("payload", payload)
|
||||
}
|
||||
|
||||
private fun component(name: String): JSONObject =
|
||||
prefs.getString("component_$name", null)?.let { JSONObject(it) } ?: JSONObject()
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
var lastInstance: RuntimeStore? = null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
package su.snw.vpn_native_bridge
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.VpnService
|
||||
import android.os.Build
|
||||
import org.json.JSONObject
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
||||
import io.flutter.plugin.common.MethodChannel.Result
|
||||
|
||||
class VpnNativeBridgePlugin : FlutterPlugin, MethodCallHandler, ActivityAware {
|
||||
private lateinit var context: Context
|
||||
private lateinit var channel: MethodChannel
|
||||
private var activity: Activity? = null
|
||||
|
||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
context = binding.applicationContext
|
||||
channel = MethodChannel(binding.binaryMessenger, "su.snw.vpnapp/native_bridge")
|
||||
channel.setMethodCallHandler(this)
|
||||
}
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: Result) {
|
||||
when (call.method) {
|
||||
"get_runtime_state" -> result.success(RuntimeStore(context).read())
|
||||
"prepare_vpn" -> {
|
||||
val intent = VpnService.prepare(context)
|
||||
if (intent == null) {
|
||||
result.success(mapOf("state" to "ready"))
|
||||
} else {
|
||||
activity?.startActivityForResult(intent, REQUEST_VPN)
|
||||
result.success(mapOf("state" to "permission_requested"))
|
||||
}
|
||||
}
|
||||
"start_vpn" -> {
|
||||
if (VpnService.prepare(context) != null) {
|
||||
result.error("VPN_PERMISSION_REQUIRED", "User consent is required", null)
|
||||
return
|
||||
}
|
||||
startService(
|
||||
requestIntent(
|
||||
Intent(context, VpnRuntimeService::class.java)
|
||||
.setAction(VpnRuntimeService.ACTION_START),
|
||||
call,
|
||||
)
|
||||
)
|
||||
result.success(mapOf("state" to "starting"))
|
||||
}
|
||||
"stop_vpn" -> {
|
||||
context.startService(Intent(context, VpnRuntimeService::class.java).setAction(VpnRuntimeService.ACTION_STOP))
|
||||
result.success(mapOf("state" to "stopping"))
|
||||
}
|
||||
"start_proxy" -> {
|
||||
startService(
|
||||
requestIntent(
|
||||
Intent(context, ProxyRuntimeService::class.java)
|
||||
.setAction(ProxyRuntimeService.ACTION_START),
|
||||
call,
|
||||
)
|
||||
)
|
||||
result.success(mapOf("state" to "starting"))
|
||||
}
|
||||
"start_hotspot_proxy" -> {
|
||||
startService(
|
||||
requestIntent(
|
||||
Intent(context, ProxyRuntimeService::class.java)
|
||||
.setAction(ProxyRuntimeService.ACTION_START_HOTSPOT),
|
||||
call,
|
||||
)
|
||||
)
|
||||
result.success(mapOf("state" to "starting"))
|
||||
}
|
||||
"stop_proxy", "stop_hotspot_proxy" -> {
|
||||
context.startService(Intent(context, ProxyRuntimeService::class.java).setAction(ProxyRuntimeService.ACTION_STOP))
|
||||
result.success(mapOf("state" to "stopping"))
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
private fun startService(intent: Intent) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) context.startForegroundService(intent)
|
||||
else context.startService(intent)
|
||||
}
|
||||
|
||||
private fun requestIntent(intent: Intent, call: MethodCall): Intent {
|
||||
val arguments = call.arguments as? Map<*, *> ?: emptyMap<Any, Any>()
|
||||
return intent.putExtra(VpnRuntimeService.EXTRA_REQUEST, JSONObject(arguments).toString())
|
||||
}
|
||||
|
||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
channel.setMethodCallHandler(null)
|
||||
}
|
||||
|
||||
override fun onAttachedToActivity(binding: ActivityPluginBinding) { activity = binding.activity }
|
||||
override fun onDetachedFromActivityForConfigChanges() { activity = null }
|
||||
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { activity = binding.activity }
|
||||
override fun onDetachedFromActivity() { activity = null }
|
||||
|
||||
companion object { private const val REQUEST_VPN = 7103 }
|
||||
}
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
package su.snw.vpn_native_bridge
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Intent
|
||||
import android.net.VpnService
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.os.ParcelFileDescriptor
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
class VpnRuntimeService : VpnService() {
|
||||
private lateinit var store: RuntimeStore
|
||||
private var tun: ParcelFileDescriptor? = null
|
||||
private var adapter: EngineAdapter? = null
|
||||
private var intentRequest: String = "{}"
|
||||
private val worker = Executors.newSingleThreadExecutor()
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
store = RuntimeStore(this)
|
||||
store.setComponent("vpn")
|
||||
createChannel()
|
||||
// Must happen immediately after startForegroundService, before engine setup.
|
||||
startForeground(NOTIFICATION_ID, notification())
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
intentRequest = intent?.getStringExtra(EXTRA_REQUEST) ?: "{}"
|
||||
when (intent?.action) {
|
||||
ACTION_START -> worker.execute { startVpn() }
|
||||
ACTION_STOP -> worker.execute { stopVpn() }
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
private fun startVpn() {
|
||||
val requestJson = intentRequest
|
||||
val input = org.json.JSONObject(requestJson)
|
||||
val engineName = input.optString("engine", "signbox")
|
||||
if (!store.canStart(engineName)) {
|
||||
store.write("error", "another engine is already active", request = input)
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
store.write("starting", request = input)
|
||||
val vpn = input.optJSONObject("vpn")
|
||||
val builder = Builder()
|
||||
.setSession("VPN App")
|
||||
.setMtu(vpn?.optInt("mtu", 1280) ?: 1280)
|
||||
.addAddress(
|
||||
vpn?.optString("tun_address", "10.0.0.1") ?: "10.0.0.1",
|
||||
vpn?.optInt("tun_prefix", 24) ?: 24,
|
||||
)
|
||||
.addRoute("0.0.0.0", 0)
|
||||
.addRoute("::", 0)
|
||||
tun = builder.establish()
|
||||
if (tun == null) {
|
||||
store.write("error", "Android did not establish the VPN interface", request = input)
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
adapter = if (engineName == "freeturn") FreeTurnAdapter() else SignBoxAdapter()
|
||||
val engineFd = if (engineName == "freeturn") {
|
||||
ParcelFileDescriptor.dup(tun!!.fileDescriptor).detachFd()
|
||||
} else {
|
||||
tun!!.fd
|
||||
}
|
||||
val result = adapter!!.start(this, requestJson, engineFd)
|
||||
if (!result.supported) {
|
||||
tun?.close()
|
||||
tun = null
|
||||
store.write("unsupported", result.message, request = input)
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
store.write("running", request = input)
|
||||
}
|
||||
|
||||
private fun stopVpn() {
|
||||
val wasActive = adapter != null || tun != null
|
||||
adapter?.stop()
|
||||
adapter = null
|
||||
tun?.close()
|
||||
tun = null
|
||||
if (wasActive) store.write("stopped")
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
stopVpn()
|
||||
worker.shutdownNow()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onRevoke() {
|
||||
stopVpn()
|
||||
super.onRevoke()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent): IBinder? = super.onBind(intent)
|
||||
|
||||
private fun createChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
getSystemService(NotificationManager::class.java).createNotificationChannel(
|
||||
NotificationChannel(CHANNEL, "VPN App", NotificationManager.IMPORTANCE_LOW)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun notification(): Notification {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
Notification.Builder(this, CHANNEL).setContentTitle("VPN App").setContentText("VPN service").setSmallIcon(android.R.drawable.stat_sys_warning).build()
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
Notification.Builder(this).setContentTitle("VPN App").setContentText("VPN service").setSmallIcon(android.R.drawable.stat_sys_warning).build()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACTION_START = "su.snw.vpnapp.START_VPN"
|
||||
const val ACTION_STOP = "su.snw.vpnapp.STOP_VPN"
|
||||
const val EXTRA_REQUEST = "su.snw.vpnapp.REQUEST"
|
||||
private const val CHANNEL = "vpnapp-runtime"
|
||||
private const val NOTIFICATION_ID = 7101
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import 'package:flet/flet.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class Extension extends FletExtension {
|
||||
@override
|
||||
void ensureInitialized() {}
|
||||
|
||||
@override
|
||||
FletService? createService(Control control) {
|
||||
if (control.type == 'vpn_native_bridge') {
|
||||
return NativeBridgeService(control: control);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget? createWidget(Key? key, Control control) => null;
|
||||
}
|
||||
|
||||
class NativeBridgeService extends FletService {
|
||||
NativeBridgeService({required super.control});
|
||||
|
||||
static const _channel = MethodChannel('su.snw.vpnapp/native_bridge');
|
||||
|
||||
@override
|
||||
void init() {
|
||||
super.init();
|
||||
control.addInvokeMethodListener(_onInvokeMethod);
|
||||
}
|
||||
|
||||
Future<dynamic> _onInvokeMethod(String name, dynamic args) async {
|
||||
try {
|
||||
return await _channel.invokeMethod<dynamic>(name, args);
|
||||
} on PlatformException catch (error) {
|
||||
return <String, dynamic>{
|
||||
'state': 'error',
|
||||
'code': error.code,
|
||||
'message': error.message,
|
||||
};
|
||||
} catch (error) {
|
||||
return <String, dynamic>{'state': 'error', 'message': error.toString()};
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
control.removeInvokeMethodListener(_onInvokeMethod);
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export 'extension.dart';
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
name: vpn_native_bridge
|
||||
description: Native Android lifecycle bridge for VPN App.
|
||||
version: 0.1.0
|
||||
publish_to: none
|
||||
environment:
|
||||
sdk: ">=3.4.0 <4.0.0"
|
||||
flutter: ">=3.22.0"
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
flet: any
|
||||
flutter:
|
||||
plugin:
|
||||
platforms:
|
||||
android:
|
||||
package: su.snw.vpn_native_bridge
|
||||
pluginClass: VpnNativeBridgePlugin
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
from .bridge import NativeBridge
|
||||
|
||||
__all__ = ["NativeBridge"]
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import flet as ft
|
||||
|
||||
|
||||
@ft.control("vpn_native_bridge")
|
||||
class NativeBridge(ft.Service):
|
||||
"""Small Flet service control for the Android native runtime contract."""
|
||||
|
||||
protocol_version: int = 1
|
||||
|
||||
async def command(self, name: str, args: dict[str, Any] | None = None) -> Any:
|
||||
return await self._invoke_method(
|
||||
name, {"protocol_version": self.protocol_version, **(args or {})}
|
||||
)
|
||||
|
||||
async def start_vpn(self, request: dict[str, Any]) -> Any:
|
||||
return await self.command("start_vpn", request)
|
||||
|
||||
async def stop_vpn(self) -> Any:
|
||||
return await self.command("stop_vpn")
|
||||
|
||||
async def start_proxy(self, request: dict[str, Any]) -> Any:
|
||||
return await self.command("start_proxy", request)
|
||||
|
||||
async def stop_proxy(self) -> Any:
|
||||
return await self.command("stop_proxy")
|
||||
|
||||
async def start_hotspot_proxy(self, request: dict[str, Any]) -> Any:
|
||||
return await self.command("start_hotspot_proxy", request)
|
||||
|
||||
async def stop_hotspot_proxy(self) -> Any:
|
||||
return await self.command("stop_hotspot_proxy")
|
||||
|
||||
async def get_runtime_state(self) -> Any:
|
||||
return await self.command("get_runtime_state")
|
||||
|
||||
async def prepare_vpn(self) -> Any:
|
||||
return await self.command("prepare_vpn")
|
||||
15
native/android/libs/README.md
Normal file
15
native/android/libs/README.md
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# Local Android engine artifacts
|
||||
|
||||
This directory is intentionally ignored by git. Put reviewed, pinned native
|
||||
artifacts here before making a release APK:
|
||||
|
||||
- `freeturn.aar` — built from the FreeTurn commit recorded in
|
||||
`THIRD_PARTY_NOTICES.md`.
|
||||
- `libbox.aar` — the exact SignBox artifact with the API used by
|
||||
`EngineAdapter.kt`; a generic sing-box AAR is not assumed to be compatible.
|
||||
|
||||
`scripts/build-android.sh` extracts `classes.jar` and `jni/*` from each AAR into
|
||||
the Flet Android extension because Gradle does not allow a direct local AAR
|
||||
dependency while building the extension AAR. It then runs the normal Flet APK
|
||||
build. Use `REQUIRE_NATIVE_ARTIFACTS=1` for a release build so no engine is
|
||||
silently omitted.
|
||||
66
pyproject.toml
Normal file
66
pyproject.toml
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
[project]
|
||||
name = "vpnapp"
|
||||
version = "0.1.0"
|
||||
description = "Private Android/Desktop VPN and proxy client with a small subscription backend."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12,<3.14"
|
||||
dependencies = [
|
||||
"flet>=0.86.0",
|
||||
"flet-webview>=0.86.5",
|
||||
"pydantic>=2.11.0",
|
||||
"qrcode>=8.2",
|
||||
"vpn-native-bridge",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=8.4.0",
|
||||
"pytest-asyncio>=1.1.0",
|
||||
"ruff>=0.12.0",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
default-groups = ["dev"]
|
||||
|
||||
[tool.uv.sources]
|
||||
vpn-native-bridge = { path = "native/android/flet_vpn_bridge" }
|
||||
|
||||
[tool.flet]
|
||||
org = "su.snw"
|
||||
product = "VPN App"
|
||||
company = "SNW"
|
||||
copyright = "Copyright (C) 2026 SNW"
|
||||
platforms = ["android", "linux", "windows"]
|
||||
|
||||
[tool.flet.app]
|
||||
path = "app"
|
||||
module = "main"
|
||||
|
||||
[tool.flet.android]
|
||||
min_sdk_version = 26
|
||||
target_sdk_version = 35
|
||||
|
||||
[tool.flet.dev_packages]
|
||||
vpn-native-bridge = "./native/android/flet_vpn_bridge"
|
||||
|
||||
[tool.flet.android.permission]
|
||||
"android.permission.INTERNET" = true
|
||||
"android.permission.ACCESS_NETWORK_STATE" = true
|
||||
"android.permission.FOREGROUND_SERVICE" = true
|
||||
"android.permission.FOREGROUND_SERVICE_SPECIAL_USE" = true
|
||||
"android.permission.POST_NOTIFICATIONS" = true
|
||||
"android.permission.NEARBY_WIFI_DEVICES" = true
|
||||
"android.permission.ACCESS_FINE_LOCATION" = true
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
pythonpath = ["."]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py312"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "UP"]
|
||||
ignore = ["E501"]
|
||||
56
scripts/build-android.sh
Executable file
56
scripts/build-android.sh
Executable file
|
|
@ -0,0 +1,56 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
command -v uv >/dev/null || { echo "uv is required" >&2; exit 1; }
|
||||
|
||||
BRIDGE_ANDROID_DIR="native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/android"
|
||||
mkdir -p "$BRIDGE_ANDROID_DIR/libs"
|
||||
# Remove artifacts from older builds that used the unsupported direct-AAR
|
||||
# dependency path. The reviewed source artifacts remain under native/android.
|
||||
rm -f "$BRIDGE_ANDROID_DIR/libs/freeturn.aar" "$BRIDGE_ANDROID_DIR/libs/libbox.aar"
|
||||
JNI_DIR="$BRIDGE_ANDROID_DIR/src/main/jniLibs"
|
||||
rm -rf "$JNI_DIR"
|
||||
mkdir -p "$JNI_DIR"
|
||||
|
||||
# A Flutter/Flet extension is itself built as an Android AAR. Gradle rejects
|
||||
# direct local .aar dependencies in that situation, so unpack only the AAR's
|
||||
# classes.jar and native libraries into the extension. The resulting plugin
|
||||
# AAR carries the same reviewed engine payload into the final APK.
|
||||
prepare_engine_artifact() {
|
||||
local artifact="$1"
|
||||
local stem="${artifact%.aar}"
|
||||
local source="native/android/libs/$artifact"
|
||||
local jar_target="$BRIDGE_ANDROID_DIR/libs/${stem}-classes.jar"
|
||||
|
||||
rm -f "$jar_target"
|
||||
if [[ -f "$source" ]]; then
|
||||
unzip -p "$source" classes.jar > "$jar_target"
|
||||
local unpack_dir
|
||||
unpack_dir="$(mktemp -d)"
|
||||
unzip -q "$source" 'jni/*' -d "$unpack_dir"
|
||||
if [[ -d "$unpack_dir/jni" ]]; then
|
||||
cp -R "$unpack_dir/jni/." "$JNI_DIR/"
|
||||
fi
|
||||
rm -rf "$unpack_dir"
|
||||
fi
|
||||
}
|
||||
|
||||
for artifact in freeturn.aar libbox.aar; do
|
||||
prepare_engine_artifact "$artifact"
|
||||
done
|
||||
|
||||
if [[ ! -f native/android/libs/freeturn.aar || ! -f native/android/libs/libbox.aar ]]; then
|
||||
cat >&2 <<'EOF'
|
||||
Warning: one or more native engine artifacts are missing.
|
||||
The APK will build, but the affected runtime reports UNSUPPORTED and cannot connect.
|
||||
For a release build set REQUIRE_NATIVE_ARTIFACTS=1 after placing reviewed, pinned files at:
|
||||
native/android/libs/freeturn.aar
|
||||
native/android/libs/libbox.aar
|
||||
EOF
|
||||
if [[ "${REQUIRE_NATIVE_ARTIFACTS:-0}" == "1" ]]; then
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
|
||||
uv run flet build apk --project vpn-app --org su.snw --artifact vpn-app-arm64 --arch arm64-v8a
|
||||
10
scripts/build-desktop.sh
Executable file
10
scripts/build-desktop.sh
Executable file
|
|
@ -0,0 +1,10 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
target="${1:-linux}"
|
||||
case "$target" in
|
||||
linux|windows) ;;
|
||||
*) echo "usage: $0 linux|windows" >&2; exit 2 ;;
|
||||
esac
|
||||
uv run flet build "$target" --project vpn-app
|
||||
23
scripts/fetch-engine-artifacts.sh
Executable file
23
scripts/fetch-engine-artifacts.sh
Executable file
|
|
@ -0,0 +1,23 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
mkdir -p native/android/libs
|
||||
|
||||
echo "This script does not download unpinned native binaries."
|
||||
echo
|
||||
echo "FreeTurn upstream (${FREETURN_REF:-master}):"
|
||||
echo " git clone --depth 1 --branch ${FREETURN_REF:-master} https://github.com/samosvalishe/free-turn-proxy.git /tmp/free-turn-proxy"
|
||||
echo " cd /tmp/free-turn-proxy && task build:android"
|
||||
echo " cp dist/freeturn.aar $PWD/native/android/libs/freeturn.aar"
|
||||
echo
|
||||
echo "SignBox/sing-box artifact: provide the exact libbox.aar/private SignBox AAR and record its source/commit in THIRD_PARTY_NOTICES.md."
|
||||
echo "The upstream build shape is: go install github.com/sagernet/gomobile/cmd/gomobile@<pinned>; make lib_android"
|
||||
echo
|
||||
for artifact in native/android/libs/freeturn.aar native/android/libs/libbox.aar; do
|
||||
if [[ -f "$artifact" ]]; then
|
||||
sha256sum "$artifact"
|
||||
else
|
||||
echo "MISSING $artifact"
|
||||
fi
|
||||
done
|
||||
13
scripts/run-backend.sh
Executable file
13
scripts/run-backend.sh
Executable file
|
|
@ -0,0 +1,13 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
if [[ -f .env ]]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1091
|
||||
source .env
|
||||
set +a
|
||||
fi
|
||||
: "${VPNAPP_ADMIN_TOKEN:?Set VPNAPP_ADMIN_TOKEN (see .env.example)}"
|
||||
: "${VPNAPP_SECRET_KEY:?Set VPNAPP_SECRET_KEY to a Fernet key (see .env.example)}"
|
||||
uv run --project backend uvicorn backend.main:app --host "${VPNAPP_HOST:-127.0.0.1}" --port "${VPNAPP_PORT:-8080}"
|
||||
5
scripts/test.sh
Executable file
5
scripts/test.sh
Executable file
|
|
@ -0,0 +1,5 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
uv run pytest
|
||||
uv run --project backend pytest backend/tests
|
||||
81
tests/test_engine_manager.py
Normal file
81
tests/test_engine_manager.py
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.errors import EngineError
|
||||
from app.core.interface import EngineBackend
|
||||
from app.core.manager import EngineManager
|
||||
from app.core.registry import BackendRegistry
|
||||
from app.models import (
|
||||
CapabilitySet,
|
||||
EngineKind,
|
||||
EngineState,
|
||||
RuntimeRequest,
|
||||
RuntimeSnapshot,
|
||||
RuntimeStats,
|
||||
)
|
||||
|
||||
|
||||
class FakeBackend(EngineBackend):
|
||||
name = "fake"
|
||||
capabilities = CapabilitySet(supports_proxy=True, supports_desktop=True)
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.state = RuntimeSnapshot(engine=EngineKind.SIGNBOX)
|
||||
|
||||
async def start(self, request: RuntimeRequest) -> RuntimeSnapshot:
|
||||
self.state = RuntimeSnapshot(
|
||||
state=EngineState.RUNNING, engine=request.engine, mode=request.mode
|
||||
)
|
||||
return self.state
|
||||
|
||||
async def stop(self) -> RuntimeSnapshot:
|
||||
self.state = self.state.model_copy(update={"state": EngineState.STOPPED})
|
||||
return self.state
|
||||
|
||||
async def restart(self, request: RuntimeRequest) -> RuntimeSnapshot:
|
||||
return await self.start(request)
|
||||
|
||||
async def get_state(self) -> RuntimeSnapshot:
|
||||
return self.state
|
||||
|
||||
async def get_stats(self) -> RuntimeStats:
|
||||
return RuntimeStats()
|
||||
|
||||
|
||||
def manager() -> EngineManager:
|
||||
registry = BackendRegistry()
|
||||
registry.register(EngineKind.SIGNBOX, FakeBackend)
|
||||
registry.register(EngineKind.FREETURN, FakeBackend)
|
||||
return EngineManager(registry)
|
||||
|
||||
|
||||
def request(engine: EngineKind) -> RuntimeRequest:
|
||||
return RuntimeRequest(engine=engine, mode="proxy", profile_id="profile")
|
||||
|
||||
|
||||
def test_manager_serializes_and_prevents_conflict() -> None:
|
||||
async def run() -> None:
|
||||
value = manager()
|
||||
assert (await value.start(request(EngineKind.SIGNBOX))).state is EngineState.RUNNING
|
||||
with pytest.raises(EngineError):
|
||||
await value.start(request(EngineKind.FREETURN))
|
||||
assert (await value.stop()).state is EngineState.STOPPED
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_registry_has_no_core_if_branch_requirement() -> None:
|
||||
value = manager()
|
||||
assert set(value.registry.kinds()) == {EngineKind.SIGNBOX, EngineKind.FREETURN}
|
||||
|
||||
|
||||
def test_manager_can_switch_engine_after_stop() -> None:
|
||||
async def run() -> None:
|
||||
value = manager()
|
||||
await value.start(request(EngineKind.SIGNBOX))
|
||||
await value.stop()
|
||||
snapshot = await value.start(request(EngineKind.FREETURN))
|
||||
assert snapshot.engine is EngineKind.FREETURN
|
||||
|
||||
asyncio.run(run())
|
||||
31
tests/test_models.py
Normal file
31
tests/test_models.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models import FreeTurnProfile, PaymentInfo, ProxyAccess, ProxySettings
|
||||
|
||||
|
||||
def test_payment_day_uses_last_day_of_month() -> None:
|
||||
payment = PaymentInfo(monthly_price_rub=500, payment_day=31)
|
||||
assert payment.due_date(2026, 2) == date(2026, 2, 28)
|
||||
assert payment.due_date(2028, 2) == date(2028, 2, 29)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("preset", "expected"),
|
||||
[(1, (4, 4)), (2, (8, 4)), (3, (12, 6)), (4, (24, 6))],
|
||||
)
|
||||
def test_freeturn_presets(preset: int, expected: tuple[int, int]) -> None:
|
||||
assert FreeTurnProfile(preset=preset).stream_settings == expected
|
||||
|
||||
|
||||
def test_freeturn_stability_mode_caps_per_credential() -> None:
|
||||
assert FreeTurnProfile(preset=3, stability_over_load=True).stream_settings == (12, 4)
|
||||
assert FreeTurnProfile(preset=4, stability_over_load=True).stream_settings == (24, 4)
|
||||
|
||||
|
||||
def test_hotspot_without_auth_is_allowed_but_auth_requires_credentials() -> None:
|
||||
settings = ProxySettings(access=ProxyAccess.HOTSPOT, authentication=False)
|
||||
settings.validate_for_access()
|
||||
with pytest.raises(ValueError):
|
||||
ProxySettings(authentication=True).validate_for_access()
|
||||
6
tests/test_proxy.py
Normal file
6
tests/test_proxy.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
from app.core.proxy import pick_free_port
|
||||
|
||||
|
||||
def test_pick_free_high_port() -> None:
|
||||
port = pick_free_port(candidates=[49152, 49153])
|
||||
assert 49152 <= port <= 49153
|
||||
19
tests/test_serialization.py
Normal file
19
tests/test_serialization.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
from app.models import AppPolicy, RunMode, RuntimeRequest, SignBoxProfile
|
||||
|
||||
|
||||
def test_profile_and_runtime_json_roundtrip() -> None:
|
||||
profile = SignBoxProfile(name="Основная", config={"type": "vless"})
|
||||
restored = SignBoxProfile.model_validate_json(profile.model_dump_json())
|
||||
assert restored.id == profile.id
|
||||
request = RuntimeRequest(
|
||||
engine="signbox",
|
||||
mode=RunMode.VPN,
|
||||
profile_id=profile.id,
|
||||
vpn={"app_rules": {"com.example.void": AppPolicy.VOID}},
|
||||
)
|
||||
assert (
|
||||
RuntimeRequest.model_validate_json(request.model_dump_json()).vpn.app_rules[
|
||||
"com.example.void"
|
||||
]
|
||||
is AppPolicy.VOID
|
||||
)
|
||||
21
tests/test_subscription.py
Normal file
21
tests/test_subscription.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
from app.services.subscription import ClientVersion, SubscriptionClient, SubscriptionError
|
||||
|
||||
|
||||
def test_user_agent_and_enrollment_url() -> None:
|
||||
values: dict[str, str] = {}
|
||||
client = SubscriptionClient(values.get, values.__setitem__, ClientVersion(1, 7))
|
||||
result = client.enroll_url("https://sub.example.com/d/123e4567-e89b-12d3-a456-426614174000?enroll=secret")
|
||||
assert result["subscription_url"].endswith("/d/123e4567-e89b-12d3-a456-426614174000")
|
||||
assert values["device_token"] == "secret"
|
||||
assert client.version.user_agent(values["device_id"]).startswith("vpnapp/v1.7/")
|
||||
|
||||
|
||||
def test_invalid_enrollment_url() -> None:
|
||||
values: dict[str, str] = {}
|
||||
client = SubscriptionClient(values.get, values.__setitem__)
|
||||
try:
|
||||
client.enroll_url("https://sub.example.com/not-an-enrollment")
|
||||
except SubscriptionError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("invalid enrollment URL must be rejected")
|
||||
405
uv.lock
generated
Normal file
405
uv.lock
generated
Normal file
|
|
@ -0,0 +1,405 @@
|
|||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.12, <3.14"
|
||||
|
||||
[[package]]
|
||||
name = "annotated-types"
|
||||
version = "0.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.15.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "idna" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.7.22"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
|
||||
]
|
||||
|
||||
[[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 = "flet"
|
||||
version = "1.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx", marker = "sys_platform != 'emscripten'" },
|
||||
{ name = "msgpack" },
|
||||
{ name = "oauthlib", marker = "sys_platform != 'emscripten'" },
|
||||
{ name = "repath" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5e/0f/ac7e2af03fd49dd28477a062f8022a362155a2608857c79d6a4272e4f4a3/flet-1.0.1.tar.gz", hash = "sha256:0c11ea010efbae34600556d964613324ea0ddc711bdac94d6592699b28dc0ba4", size = 571070, upload-time = "2026-09-22T20:24:47.549Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/86/6eaa2f957931ee03f9814838259d5df395295c7807673b9ea2a30f75f4c5/flet-1.0.1-py3-none-any.whl", hash = "sha256:94a15d75f587dc0b6a2479348f2e4b2bf40606469eb5a18dc62ab12a790f1ae2", size = 633955, upload-time = "2026-09-22T20:24:46.225Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flet-webview"
|
||||
version = "1.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "flet" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/37/3f/29d5e29afd4ef2dde7e84a724bc1a695aeab8f5af0a95877038dc971a237/flet_webview-1.0.1.tar.gz", hash = "sha256:1ff96f8430514a52d59e31cbe7313ba9417b12ccbc02836529f2bf88a683a647", size = 14630, upload-time = "2026-09-22T20:25:59.329Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/7b/047106dfb7e012e46ed3c6ca6032381afcc269715d8211b646eb2b616816/flet_webview-1.0.1-py3-none-any.whl", hash = "sha256:ce3143f5ab82eb23c8e173f57b5c1536bebeba60d90ca1ba84a5284f950b7b4c", size = 18345, upload-time = "2026-09-22T20:25:58.118Z" },
|
||||
]
|
||||
|
||||
[[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.20"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" },
|
||||
]
|
||||
|
||||
[[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 = "msgpack"
|
||||
version = "1.2.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6d/44/ea2100ec54d30c46ee9dba10a3bfb79b655e96c6df237238a3234c75869b/msgpack-1.2.2.tar.gz", hash = "sha256:9eb0b0e602064527a045ea28c4f174ed69383587e29cebe28947e3b84106eb2a", size = 187025, upload-time = "2026-08-27T10:03:47.793Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/31/78/90c15bebb1a72667349ca62d4507e9d9369e7f8f76b95f490b823d3622e5/msgpack-1.2.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a4348705be86e029d04e741cf9ed0dfe03e942d7d3b92e838fa80d3aa2c3ebc", size = 84275, upload-time = "2026-08-27T10:02:07.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/88/c2b6d8e81571da87aa232c0e34a3f3a0e618e6235892065ec82d1d81fc7a/msgpack-1.2.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0a652ceeededf71d3fa40c303a02a149d42338d310162367b91c539d4bd6e0a3", size = 83970, upload-time = "2026-08-27T10:02:08.488Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/c0/d3ede9f5d16acb4c05a9281859f1e99ef9f877a928eb78454c37f70db001/msgpack-1.2.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90986cc9aab9d7d1d8f38bcbf65d3f7ac83bdd90c35765db7d691b4829698cba", size = 409401, upload-time = "2026-08-27T10:02:09.877Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/f0/29f591bea185616cf417645ac03bd3ad9b317483ad8572160e325f7fe777/msgpack-1.2.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:77c2e018417dc1d66f235e383877ee885b60ade9d29e494dd581e08af2cb1923", size = 420619, upload-time = "2026-08-27T10:02:11.526Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/8e/c70c8c9180c5ddf4440eb8658ebead98e22e7686fbf84f6b165031430750/msgpack-1.2.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0e91332144f69bc3018c91232fac26da580ef748fb8eaddd7914d4458001cc4f", size = 379747, upload-time = "2026-08-27T10:02:13.345Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/9a/f10ce11fa62700c9ab87a22e65b9ca272f7f673ddd31aeb2de6ae272ad35/msgpack-1.2.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3e915d390d7068b257ca8b62f3fc59fad135c8631d1017ab03b0b924b07c5367", size = 398944, upload-time = "2026-08-27T10:02:15.006Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/fe/d7be978456ff8552e69a8e270d882e7530e01513c096b293d83df03753ea/msgpack-1.2.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c522420d78db2431887d45b518e304d86e27b9ad0b30f24e3806a6ad5d8bdbfc", size = 373979, upload-time = "2026-08-27T10:02:16.618Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/af/91b0d8d3fb3063e259daee3ea8515cea6282f68f4b0e5f0b6fea25762c6e/msgpack-1.2.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4b554d8164ebb526892194f71dcd96ef1fefe0c250087498785d3ffc04a80be3", size = 417781, upload-time = "2026-08-27T10:02:18.293Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/3c/ce8e9efe1fd9e95c78b3705e4300ba7feba3dc6c00fb76259895db155518/msgpack-1.2.2-cp312-cp312-win32.whl", hash = "sha256:0e3315de5a4b2920ccef48d96b4448025e064a10d0f5a250f6584477d839c8d4", size = 65267, upload-time = "2026-08-27T10:02:19.869Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/98/a33b8b4af14e3476bb0da1b8c36ef7a0f28dcf95db1c5e68ff88cb89d591/msgpack-1.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:b68614fba0570349833b7dd999ff0aed4e5cc8d9eb6e3a7d4527be33c65e33d3", size = 72275, upload-time = "2026-08-27T10:02:21.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/5e/2f323a33a6aba5bd4b2d8b430e4fab21d92cd91c093b49ee287bc166ee54/msgpack-1.2.2-cp312-cp312-win_arm64.whl", hash = "sha256:59d5b93efa45fd09f620d0c9ba81cde339a2c9937af3eea42ee9653094ce6640", size = 65488, upload-time = "2026-08-27T10:02:22.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/eb/42f31c5a48811787ff59a9869721f70a49654d65ab6c455f4463c39b044e/msgpack-1.2.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8b2a281b556f120a43e591ea39915741b7ad54d4727b9c4350a0a11692252533", size = 83911, upload-time = "2026-08-27T10:02:24.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/54/10c6c16ddba8a5112e3680176b838e3694e4aad7284f9daa6d6d70d98817/msgpack-1.2.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1e8cdd1f3e7cc52c751092a9bf740e81e6919ab109cd376ae2d965dad0bbae34", size = 83734, upload-time = "2026-08-27T10:02:25.613Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/75/35823e4419df8792191b2a17ae3fe71b41d02c162b2c491c94d1a87f0caa/msgpack-1.2.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1814f92306ae7862908e9ece7cfd90e0dc87ded3e89b6ae7ffdd1175d6376fdc", size = 405635, upload-time = "2026-08-27T10:02:27.012Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/d3/6592e4064619b04f2dd0054c5fa13e37e3d55eb26044483d871fadb2f46b/msgpack-1.2.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d24b38a825bcca41bb956de50eb98451ef291304a8607fad99e619043d3e79b9", size = 417332, upload-time = "2026-08-27T10:02:28.776Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/a1/b21c6818a545e9a4a976ac954a5c250eecde9a02e0ec82f415473dab1324/msgpack-1.2.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34e83e345194a2a51d8bd447dea9de2104f91e75b247f4735f14f04529f0746b", size = 374378, upload-time = "2026-08-27T10:02:30.678Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/8b/7ada15c7b64151d6dbb562d1b091520efb2c37acf2403b1d4ae13797b27d/msgpack-1.2.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:682804bf31e43d46e51a9a33bd575b51e839d715ce6bd5612c055f7b28ad637b", size = 395809, upload-time = "2026-08-27T10:02:32.322Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/f7/96283e50f7020df4dfeacc55612b7a210c8cdf0dda48bc262f1f9b3e4c49/msgpack-1.2.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9b659d77f8726fa5e7038967dda6b68d53cf34472c094cfa5b845454713b90d5", size = 373495, upload-time = "2026-08-27T10:02:33.832Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/fe/1548dede9d9ca482f2d424a2e110a9705d4e02627a16b8bc8d10ce0208a2/msgpack-1.2.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4d9a562aec0a92fe536da2e533d313b3d2a6b929157b1dec7ff623446dc0a8ab", size = 414360, upload-time = "2026-08-27T10:02:35.396Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/9d/4419b8f86c219174b1fb8bbd7faaf84a548935f7b1916d028401b9433417/msgpack-1.2.2-cp313-cp313-win32.whl", hash = "sha256:a4161eee7799863aee237c35c90427861f7b994416dd81ae829f560b0a81bdcd", size = 65196, upload-time = "2026-08-27T10:02:37.007Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/f8/593f5caf0dacab41cde1564c5f0419e61af55ec9628006205e8fd5eb5e03/msgpack-1.2.2-cp313-cp313-win_amd64.whl", hash = "sha256:b07c03f0da7e5279170df7745ddc732d526c8a198208936ec1a95c11ed2b2d5f", size = 72203, upload-time = "2026-08-27T10:02:38.28Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/9e/c6ef92046b4a2bbb9d3aa0cb581cbf4a4051afccf6e5fb301a1bd3086f39/msgpack-1.2.2-cp313-cp313-win_arm64.whl", hash = "sha256:d13d07efbf655f9ae7a2352b630c52727b359005b21ba08a507585c9ac8c0896", size = 65435, upload-time = "2026-08-27T10:02:39.534Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oauthlib"
|
||||
version = "3.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
|
||||
]
|
||||
|
||||
[[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.5"
|
||||
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/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.46.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.21.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.1.1"
|
||||
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/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-asyncio"
|
||||
version = "1.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pytest" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "qrcode"
|
||||
version = "8.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8f/b2/7fc2931bfae0af02d5f53b174e9cf701adbb35f39d69c2af63d4a39f81a9/qrcode-8.2.tar.gz", hash = "sha256:35c3f2a4172b33136ab9f6b3ef1c00260dd2f66f858f24d88418a015f446506c", size = 43317, upload-time = "2025-05-01T15:44:24.726Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/b8/d2d6d731733f51684bbf76bf34dab3b70a9148e8f2cef2bb544fccec681a/qrcode-8.2-py3-none-any.whl", hash = "sha256:16e64e0716c14960108e85d853062c9e8bba5ca8252c0b4d0231b9df4060ff4f", size = 45986, upload-time = "2025-05-01T15:44:22.781Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "repath"
|
||||
version = "0.9.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "six" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/65/e1/824989291d0f01886074fdf9504ba54598f5665bc4dd373b589b87e76608/repath-0.9.0.tar.gz", hash = "sha256:8292139bac6a0e43fd9d70605d4e8daeb25d46672e484ed31a24c7ce0aef0fb7", size = 5492, upload-time = "2019-10-08T00:25:22.3Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/87/ed/92e9b8a3ffc562f21df14ef2538f54e911df29730e1f0d79130a4edc86e7/repath-0.9.0-py3-none-any.whl", hash = "sha256:ee079d6c91faeb843274d22d8f786094ee01316ecfe293a1eb6546312bb6a318", size = 4738, upload-time = "2019-10-08T00:25:20.842Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.16.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ba/78/449cb84790bd5cc3823b2652ee405a4558856e5c4195aee3a16bf7b3eb5d/ruff-0.16.8.tar.gz", hash = "sha256:9247bf92b5f04d825c8639a4fe423ec2e4222acd9222e58412b0dab7e442798b", size = 4938814, upload-time = "2026-09-16T15:54:46.688Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/25/6071aabc530e9be7e2c195e8fe3f7aea2735405b6cf447212832d7811831/ruff-0.16.8-py3-none-linux_armv6l.whl", hash = "sha256:6ffbd6d87383c1edf5f6fa890f10200950240d7c1a16052a19a09d3a2307dd38", size = 10048966, upload-time = "2026-09-16T15:53:57.605Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/98/07f90ecbc74dd5fb5764f11f2bc774d6a7cffef92d2ff5f5b4e9e23c754e/ruff-0.16.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:42ed6b878ed61e3acca92f2730a17acff39286944ea82398544696366a6f925e", size = 10165498, upload-time = "2026-09-16T15:54:01.14Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/1f/e6a712e3b47cad4a40600134105ed193cb773f618a42eb7ba323cb812cc0/ruff-0.16.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ea781c7f2afba8c6a505ea0fb3f994020249e0c450635f5381286fea6b46170", size = 9830004, upload-time = "2026-09-16T15:54:03.998Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/f2/311a08776d75d81c7676e20b6b020ae63cbe881fcdc7a8dd64e6e18bdd93/ruff-0.16.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8efeae3bbe414a5efefda11a792dfb51ef90ac48d50c4830de2f644caf3e8659", size = 9986558, upload-time = "2026-09-16T15:54:06.804Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/ed/37b6cb3d3ba8c73e68ae3eb1d502383beb5aa05a582bb7bb3a922f929f54/ruff-0.16.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a79b795469fef7fc6e908b218eed2eb17332afd85031db6480dc864560e69b2", size = 9877332, upload-time = "2026-09-16T15:54:09.552Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/cc/40873a8f36ad084cc540d55fcca7077264d5b13b24659e9180c176fb2b08/ruff-0.16.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fdc5563cdc50555e6fba39322850860e9267c1b3d12c26a74729d8604c3c812", size = 10507125, upload-time = "2026-09-16T15:54:12.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/e4/fc91a642b78ccbab6b9477720f3644ae7a10a9bcce69a934679cd64f62bc/ruff-0.16.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:34508983c70665578dab88f5223d8e6228307e1135398ca8bfc8b7e9501e282b", size = 11336694, upload-time = "2026-09-16T15:54:15.489Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/3d/bbd2a9a600a4e73dc3e7548a249c8d1671273464b55822c6fae50f602dff/ruff-0.16.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:644bb578569e0ffc575741232bd385dacdd6fbe123f1a729e7a225f54aa3957f", size = 10774448, upload-time = "2026-09-16T15:54:18.16Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/41/d83af9879a7b6e8bf5fe16b1da0b134049d2f5d3afac12defb0897cb84bd/ruff-0.16.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15e7d226246961db9235098333caa13063906d3851136b84c2900b82f5daa1df", size = 10323796, upload-time = "2026-09-16T15:54:20.743Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/2c/cefd07bfe914b84943ea769ade8d607bd22750b965d3228eefd7cebd15d0/ruff-0.16.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a2bf6bc3e9ebdd4449abc6f06cf64b98051a2c61cf94d2fe9596518c881f1a1e", size = 10514115, upload-time = "2026-09-16T15:54:23.497Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/9d/76a2e26c79a23be6e6e3664c57bec9e9fc8de155cfb9e4b67ea91b64f9d7/ruff-0.16.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6ca111ba0849539165e9e59d2b442542f3c1e8060ebbdea82494f1ffbccb1e1f", size = 10072582, upload-time = "2026-09-16T15:54:26.185Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/d4/f42edddb39668af1a559ceafa3823aedd65633a48dc9768e775485faa2c1/ruff-0.16.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:359a1e5b495448ee1e91018064382ebc86f90e8aac2fed222c7d0e4e8df85fd2", size = 9879644, upload-time = "2026-09-16T15:54:29.278Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/d4/913e3195d95e0378786c6656945c865f534a3560e29139da4882aff630d1/ruff-0.16.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:59e8f5681349474110b24d62e93cfda6593f5fa3473446ca3705200cac1a08b9", size = 10231569, upload-time = "2026-09-16T15:54:32.036Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/c4/8aa6ea0bdcedbd1bf87397e2fc4ed8406448ea5842f8660bc6e5f163039d/ruff-0.16.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:efa3e7a16d1baaa79957888dfdf8be9ef2e44db81cb032af06d76632ab59e773", size = 10663666, upload-time = "2026-09-16T15:54:34.838Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/02/7f10ef4700bc223c30a3fdd10631a29830c45524b810a3c7ed947af64591/ruff-0.16.8-py3-none-win32.whl", hash = "sha256:55793ba85c69921e89be061426d91a78652d6e50317c962240922747a4eb713f", size = 10093472, upload-time = "2026-09-16T15:54:37.47Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/5d/a509c07d714b6da88f2c518b4637cf6f1d46b074be8f0f1e5fb9ff5126fe/ruff-0.16.8-py3-none-win_amd64.whl", hash = "sha256:a6b85621fd3c81e31fc5f5add09c9c078b430db3595ca632efafdec9e64ebfaa", size = 10586899, upload-time = "2026-09-16T15:54:40.488Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/a0/50787329e4f20bf9dc9f6230015d46ec69c51a97ace5bc202dae4755365d/ruff-0.16.8-py3-none-win_arm64.whl", hash = "sha256:d075e820af612102ce217f07cc93e69f9490b10ec13ea85fa87bd03d996cef8a", size = 10386316, upload-time = "2026-09-16T15:54:43.332Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "six"
|
||||
version = "1.17.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-inspection"
|
||||
version = "0.4.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vpn-native-bridge"
|
||||
version = "0.1.0"
|
||||
source = { directory = "native/android/flet_vpn_bridge" }
|
||||
dependencies = [
|
||||
{ name = "flet" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [{ name = "flet", specifier = ">=0.86.0" }]
|
||||
|
||||
[[package]]
|
||||
name = "vpnapp"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "flet" },
|
||||
{ name = "flet-webview" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "qrcode" },
|
||||
{ name = "vpn-native-bridge" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "flet", specifier = ">=0.86.0" },
|
||||
{ name = "flet-webview", specifier = ">=0.86.5" },
|
||||
{ name = "pydantic", specifier = ">=2.11.0" },
|
||||
{ name = "qrcode", specifier = ">=8.2" },
|
||||
{ name = "vpn-native-bridge", directory = "native/android/flet_vpn_bridge" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "pytest", specifier = ">=8.4.0" },
|
||||
{ name = "pytest-asyncio", specifier = ">=1.1.0" },
|
||||
{ name = "ruff", specifier = ">=0.12.0" },
|
||||
]
|
||||
Loading…
Add table
Reference in a new issue