From 449ce767515b93afe32415fc7ad8416273f491bb Mon Sep 17 00:00:00 2001 From: SNW Date: Wed, 23 Sep 2026 01:19:36 +0000 Subject: [PATCH] Initial Flet VPN monorepo skeleton --- .env.example | 4 + .gitignore | 19 + LICENSE | 21 + README.md | 82 +++ THIRD_PARTY_NOTICES.md | 19 + app/__init__.py | 1 + app/core/__init__.py | 1 + app/core/errors.py | 6 + app/core/factory.py | 23 + app/core/freeturn.py | 64 +++ app/core/interface.py | 31 ++ app/core/manager.py | 106 ++++ app/core/native.py | 208 +++++++ app/core/proxy.py | 36 ++ app/core/registry.py | 27 + app/core/signbox.py | 153 ++++++ app/main.py | 124 +++++ app/models.py | 199 +++++++ app/screens/__init__.py | 1 + app/screens/components.py | 52 ++ app/screens/safe_area.py | 7 + app/screens/settings.py | 39 ++ app/screens/vpn.py | 188 +++++++ app/screens/white_lists.py | 155 ++++++ app/services/__init__.py | 1 + app/services/billing.py | 26 + app/services/controller.py | 168 ++++++ app/services/settings_registry.py | 22 + app/services/state_store.py | 32 ++ app/services/subscription.py | 96 ++++ app/theme.py | 24 + backend/__init__.py | 1 + backend/admin.html | 57 ++ backend/config.py | 30 + backend/db.py | 183 +++++++ backend/main.py | 116 ++++ backend/pyproject.toml | 20 + backend/schemas.py | 21 + backend/security.py | 33 ++ backend/tests/test_backend.py | 41 ++ backend/uv.lock | 518 ++++++++++++++++++ docs/android-manual-tests.md | 29 + docs/architecture.md | 102 ++++ docs/backend.md | 26 + docs/building.md | 70 +++ docs/implementation-status.md | 38 ++ docs/upstream-research.md | 50 ++ native/android/README.md | 40 ++ native/android/flet_vpn_bridge/pyproject.toml | 19 + .../flet_vpn_bridge/src/flutter/__init__.py | 1 + .../src/flutter/vpn_native_bridge/__init__.py | 1 + .../vpn_native_bridge/android/build.gradle | 55 ++ .../android/src/main/AndroidManifest.xml | 31 ++ .../su/snw/vpn_native_bridge/EngineAdapter.kt | 120 ++++ .../vpn_native_bridge/HotspotController.kt | 82 +++ .../vpn_native_bridge/ProxyRuntimeService.kt | 151 +++++ .../su/snw/vpn_native_bridge/RuntimeStore.kt | 109 ++++ .../VpnNativeBridgePlugin.kt | 106 ++++ .../vpn_native_bridge/VpnRuntimeService.kt | 129 +++++ .../vpn_native_bridge/lib/extension.dart | 51 ++ .../lib/vpn_native_bridge.dart | 1 + .../flutter/vpn_native_bridge/pubspec.yaml | 17 + .../src/vpn_native_bridge/__init__.py | 3 + .../src/vpn_native_bridge/bridge.py | 41 ++ native/android/libs/README.md | 15 + pyproject.toml | 66 +++ scripts/build-android.sh | 56 ++ scripts/build-desktop.sh | 10 + scripts/fetch-engine-artifacts.sh | 23 + scripts/run-backend.sh | 13 + scripts/test.sh | 5 + tests/test_engine_manager.py | 81 +++ tests/test_models.py | 31 ++ tests/test_proxy.py | 6 + tests/test_serialization.py | 19 + tests/test_subscription.py | 21 + uv.lock | 405 ++++++++++++++ 77 files changed, 4978 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 THIRD_PARTY_NOTICES.md create mode 100644 app/__init__.py create mode 100644 app/core/__init__.py create mode 100644 app/core/errors.py create mode 100644 app/core/factory.py create mode 100644 app/core/freeturn.py create mode 100644 app/core/interface.py create mode 100644 app/core/manager.py create mode 100644 app/core/native.py create mode 100644 app/core/proxy.py create mode 100644 app/core/registry.py create mode 100644 app/core/signbox.py create mode 100644 app/main.py create mode 100644 app/models.py create mode 100644 app/screens/__init__.py create mode 100644 app/screens/components.py create mode 100644 app/screens/safe_area.py create mode 100644 app/screens/settings.py create mode 100644 app/screens/vpn.py create mode 100644 app/screens/white_lists.py create mode 100644 app/services/__init__.py create mode 100644 app/services/billing.py create mode 100644 app/services/controller.py create mode 100644 app/services/settings_registry.py create mode 100644 app/services/state_store.py create mode 100644 app/services/subscription.py create mode 100644 app/theme.py create mode 100644 backend/__init__.py create mode 100644 backend/admin.html create mode 100644 backend/config.py create mode 100644 backend/db.py create mode 100644 backend/main.py create mode 100644 backend/pyproject.toml create mode 100644 backend/schemas.py create mode 100644 backend/security.py create mode 100644 backend/tests/test_backend.py create mode 100644 backend/uv.lock create mode 100644 docs/android-manual-tests.md create mode 100644 docs/architecture.md create mode 100644 docs/backend.md create mode 100644 docs/building.md create mode 100644 docs/implementation-status.md create mode 100644 docs/upstream-research.md create mode 100644 native/android/README.md create mode 100644 native/android/flet_vpn_bridge/pyproject.toml create mode 100644 native/android/flet_vpn_bridge/src/flutter/__init__.py create mode 100644 native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/__init__.py create mode 100644 native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/android/build.gradle create mode 100644 native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/android/src/main/AndroidManifest.xml create mode 100644 native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/android/src/main/kotlin/su/snw/vpn_native_bridge/EngineAdapter.kt create mode 100644 native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/android/src/main/kotlin/su/snw/vpn_native_bridge/HotspotController.kt create mode 100644 native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/android/src/main/kotlin/su/snw/vpn_native_bridge/ProxyRuntimeService.kt create mode 100644 native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/android/src/main/kotlin/su/snw/vpn_native_bridge/RuntimeStore.kt create mode 100644 native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/android/src/main/kotlin/su/snw/vpn_native_bridge/VpnNativeBridgePlugin.kt create mode 100644 native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/android/src/main/kotlin/su/snw/vpn_native_bridge/VpnRuntimeService.kt create mode 100644 native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/lib/extension.dart create mode 100644 native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/lib/vpn_native_bridge.dart create mode 100644 native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/pubspec.yaml create mode 100644 native/android/flet_vpn_bridge/src/vpn_native_bridge/__init__.py create mode 100644 native/android/flet_vpn_bridge/src/vpn_native_bridge/bridge.py create mode 100644 native/android/libs/README.md create mode 100644 pyproject.toml create mode 100755 scripts/build-android.sh create mode 100755 scripts/build-desktop.sh create mode 100755 scripts/fetch-engine-artifacts.sh create mode 100755 scripts/run-backend.sh create mode 100755 scripts/test.sh create mode 100644 tests/test_engine_manager.py create mode 100644 tests/test_models.py create mode 100644 tests/test_proxy.py create mode 100644 tests/test_serialization.py create mode 100644 tests/test_subscription.py create mode 100644 uv.lock diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6b3a742 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d59379a --- /dev/null +++ b/.gitignore @@ -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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5785efd --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..85a378c --- /dev/null +++ b/README.md @@ -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. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..3d6460f --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -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. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e589e59 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +"""Flet application package.""" diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..93056ca --- /dev/null +++ b/app/core/__init__.py @@ -0,0 +1 @@ +"""Engine abstraction and adapters.""" diff --git a/app/core/errors.py b/app/core/errors.py new file mode 100644 index 0000000..9f39f22 --- /dev/null +++ b/app/core/errors.py @@ -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.""" diff --git a/app/core/factory.py b/app/core/factory.py new file mode 100644 index 0000000..b253bbc --- /dev/null +++ b/app/core/factory.py @@ -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) diff --git a/app/core/freeturn.py b/app/core/freeturn.py new file mode 100644 index 0000000..6472d72 --- /dev/null +++ b/app/core/freeturn.py @@ -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 diff --git a/app/core/interface.py b/app/core/interface.py new file mode 100644 index 0000000..142420f --- /dev/null +++ b/app/core/interface.py @@ -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: ... diff --git a/app/core/manager.py b/app/core/manager.py new file mode 100644 index 0000000..94a493b --- /dev/null +++ b/app/core/manager.py @@ -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() diff --git a/app/core/native.py b/app/core/native.py new file mode 100644 index 0000000..cba406c --- /dev/null +++ b/app/core/native.py @@ -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 [] diff --git a/app/core/proxy.py b/app/core/proxy.py new file mode 100644 index 0000000..f0cb241 --- /dev/null +++ b/app/core/proxy.py @@ -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 diff --git a/app/core/registry.py b/app/core/registry.py new file mode 100644 index 0000000..110f203 --- /dev/null +++ b/app/core/registry.py @@ -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) diff --git a/app/core/signbox.py b/app/core/signbox.py new file mode 100644 index 0000000..2b56ea2 --- /dev/null +++ b/app/core/signbox.py @@ -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}) diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..985c099 --- /dev/null +++ b/app/main.py @@ -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) diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..92dcb28 --- /dev/null +++ b/app/models.py @@ -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()) diff --git a/app/screens/__init__.py b/app/screens/__init__.py new file mode 100644 index 0000000..eef0405 --- /dev/null +++ b/app/screens/__init__.py @@ -0,0 +1 @@ +"""Small Flet screens.""" diff --git a/app/screens/components.py b/app/screens/components.py new file mode 100644 index 0000000..76ac294 --- /dev/null +++ b/app/screens/components.py @@ -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) diff --git a/app/screens/safe_area.py b/app/screens/safe_area.py new file mode 100644 index 0000000..30a6b8f --- /dev/null +++ b/app/screens/safe_area.py @@ -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)) diff --git a/app/screens/settings.py b/app/screens/settings.py new file mode 100644 index 0000000..b11e49f --- /dev/null +++ b/app/screens/settings.py @@ -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 diff --git a/app/screens/vpn.py b/app/screens/vpn.py new file mode 100644 index 0000000..cbf947e --- /dev/null +++ b/app/screens/vpn.py @@ -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 diff --git a/app/screens/white_lists.py b/app/screens/white_lists.py new file mode 100644 index 0000000..ee37d77 --- /dev/null +++ b/app/screens/white_lists.py @@ -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 diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..a69ee20 --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1 @@ +"""Application services.""" diff --git a/app/services/billing.py b/app/services/billing.py new file mode 100644 index 0000000..01bf7c6 --- /dev/null +++ b/app/services/billing.py @@ -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}" diff --git a/app/services/controller.py b/app/services/controller.py new file mode 100644 index 0000000..bd8d7f6 --- /dev/null +++ b/app/services/controller.py @@ -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=(",", ":")) diff --git a/app/services/settings_registry.py b/app/services/settings_registry.py new file mode 100644 index 0000000..e119bc8 --- /dev/null +++ b/app/services/settings_registry.py @@ -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) diff --git a/app/services/state_store.py b/app/services/state_store.py new file mode 100644 index 0000000..245cd81 --- /dev/null +++ b/app/services/state_store.py @@ -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) diff --git a/app/services/subscription.py b/app/services/subscription.py new file mode 100644 index 0000000..9353e37 --- /dev/null +++ b/app/services/subscription.py @@ -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 diff --git a/app/theme.py b/app/theme.py new file mode 100644 index 0000000..a99762a --- /dev/null +++ b/app/theme.py @@ -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)) diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..55f9129 --- /dev/null +++ b/backend/__init__.py @@ -0,0 +1 @@ +"""Small subscription backend.""" diff --git a/backend/admin.html b/backend/admin.html new file mode 100644 index 0000000..173a86c --- /dev/null +++ b/backend/admin.html @@ -0,0 +1,57 @@ + + + + + + VPN App — управление + + + +

VPN App · управление

+
+ + +
+
+
+

Новый пользователь

+
+ + + + + + + +
+
+
+

Пользователи

+
Введите токен и нажмите «Войти / обновить».
+
+ + + diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..09d24f1 --- /dev/null +++ b/backend/config.py @@ -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"), + ) diff --git a/backend/db.py b/backend/db.py new file mode 100644 index 0000000..e28f4c5 --- /dev/null +++ b/backend/db.py @@ -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"], + } diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..43e54d9 --- /dev/null +++ b/backend/main.py @@ -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 diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..2f04c95 --- /dev/null +++ b/backend/pyproject.toml @@ -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 = [".."] diff --git a/backend/schemas.py b/backend/schemas.py new file mode 100644 index 0000000..e5cac99 --- /dev/null +++ b/backend/schemas.py @@ -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) diff --git a/backend/security.py b/backend/security.py new file mode 100644 index 0000000..ec7c39b --- /dev/null +++ b/backend/security.py @@ -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\d+)\.(?P\d+)/(?P[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 diff --git a/backend/tests/test_backend.py b/backend/tests/test_backend.py new file mode 100644 index 0000000..dfa5bdc --- /dev/null +++ b/backend/tests/test_backend.py @@ -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 diff --git a/backend/uv.lock b/backend/uv.lock new file mode 100644 index 0000000..7126165 --- /dev/null +++ b/backend/uv.lock @@ -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" }, +] diff --git a/docs/android-manual-tests.md b/docs/android-manual-tests.md new file mode 100644 index 0000000..7779b4c --- /dev/null +++ b/docs/android-manual-tests.md @@ -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. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..ebfdebe --- /dev/null +++ b/docs/architecture.md @@ -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. diff --git a/docs/backend.md b/docs/backend.md new file mode 100644 index 0000000..4fe7e1e --- /dev/null +++ b/docs/backend.md @@ -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: ` 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. diff --git a/docs/building.md b/docs/building.md new file mode 100644 index 0000000..4b29d42 --- /dev/null +++ b/docs/building.md @@ -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 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. diff --git a/docs/implementation-status.md b/docs/implementation-status.md new file mode 100644 index 0000000..5f2ad06 --- /dev/null +++ b/docs/implementation-status.md @@ -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. diff --git a/docs/upstream-research.md b/docs/upstream-research.md new file mode 100644 index 0000000..cda63f3 --- /dev/null +++ b/docs/upstream-research.md @@ -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. diff --git a/native/android/README.md b/native/android/README.md new file mode 100644 index 0000000..311a879 --- /dev/null +++ b/native/android/README.md @@ -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. diff --git a/native/android/flet_vpn_bridge/pyproject.toml b/native/android/flet_vpn_bridge/pyproject.toml new file mode 100644 index 0000000..c42bb15 --- /dev/null +++ b/native/android/flet_vpn_bridge/pyproject.toml @@ -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" = ["**/*"] diff --git a/native/android/flet_vpn_bridge/src/flutter/__init__.py b/native/android/flet_vpn_bridge/src/flutter/__init__.py new file mode 100644 index 0000000..9691a6d --- /dev/null +++ b/native/android/flet_vpn_bridge/src/flutter/__init__.py @@ -0,0 +1 @@ +"""Namespace package containing the bundled Flutter extension.""" diff --git a/native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/__init__.py b/native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/__init__.py new file mode 100644 index 0000000..72f0cb1 --- /dev/null +++ b/native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/__init__.py @@ -0,0 +1 @@ +"""Packaged Flutter extension files for Flet build discovery.""" diff --git a/native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/android/build.gradle b/native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/android/build.gradle new file mode 100644 index 0000000..64799e2 --- /dev/null +++ b/native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/android/build.gradle @@ -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") + } +} diff --git a/native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/android/src/main/AndroidManifest.xml b/native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..eb9c8e4 --- /dev/null +++ b/native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/android/src/main/AndroidManifest.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/android/src/main/kotlin/su/snw/vpn_native_bridge/EngineAdapter.kt b/native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/android/src/main/kotlin/su/snw/vpn_native_bridge/EngineAdapter.kt new file mode 100644 index 0000000..43175ba --- /dev/null +++ b/native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/android/src/main/kotlin/su/snw/vpn_native_bridge/EngineAdapter.kt @@ -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 = 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) + } +} diff --git a/native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/android/src/main/kotlin/su/snw/vpn_native_bridge/HotspotController.kt b/native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/android/src/main/kotlin/su/snw/vpn_native_bridge/HotspotController.kt new file mode 100644 index 0000000..01beb42 --- /dev/null +++ b/native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/android/src/main/kotlin/su/snw/vpn_native_bridge/HotspotController.kt @@ -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) diff --git a/native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/android/src/main/kotlin/su/snw/vpn_native_bridge/ProxyRuntimeService.kt b/native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/android/src/main/kotlin/su/snw/vpn_native_bridge/ProxyRuntimeService.kt new file mode 100644 index 0000000..0ccf032 --- /dev/null +++ b/native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/android/src/main/kotlin/su/snw/vpn_native_bridge/ProxyRuntimeService.kt @@ -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 + } +} diff --git a/native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/android/src/main/kotlin/su/snw/vpn_native_bridge/RuntimeStore.kt b/native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/android/src/main/kotlin/su/snw/vpn_native_bridge/RuntimeStore.kt new file mode 100644 index 0000000..a87c736 --- /dev/null +++ b/native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/android/src/main/kotlin/su/snw/vpn_native_bridge/RuntimeStore.kt @@ -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 { + 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 + } +} diff --git a/native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/android/src/main/kotlin/su/snw/vpn_native_bridge/VpnNativeBridgePlugin.kt b/native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/android/src/main/kotlin/su/snw/vpn_native_bridge/VpnNativeBridgePlugin.kt new file mode 100644 index 0000000..928dd46 --- /dev/null +++ b/native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/android/src/main/kotlin/su/snw/vpn_native_bridge/VpnNativeBridgePlugin.kt @@ -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() + 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 } +} diff --git a/native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/android/src/main/kotlin/su/snw/vpn_native_bridge/VpnRuntimeService.kt b/native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/android/src/main/kotlin/su/snw/vpn_native_bridge/VpnRuntimeService.kt new file mode 100644 index 0000000..7ee742c --- /dev/null +++ b/native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/android/src/main/kotlin/su/snw/vpn_native_bridge/VpnRuntimeService.kt @@ -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 + } +} diff --git a/native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/lib/extension.dart b/native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/lib/extension.dart new file mode 100644 index 0000000..6d0d20c --- /dev/null +++ b/native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/lib/extension.dart @@ -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 _onInvokeMethod(String name, dynamic args) async { + try { + return await _channel.invokeMethod(name, args); + } on PlatformException catch (error) { + return { + 'state': 'error', + 'code': error.code, + 'message': error.message, + }; + } catch (error) { + return {'state': 'error', 'message': error.toString()}; + } + } + + @override + void dispose() { + control.removeInvokeMethodListener(_onInvokeMethod); + super.dispose(); + } +} diff --git a/native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/lib/vpn_native_bridge.dart b/native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/lib/vpn_native_bridge.dart new file mode 100644 index 0000000..b0b9042 --- /dev/null +++ b/native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/lib/vpn_native_bridge.dart @@ -0,0 +1 @@ +export 'extension.dart'; diff --git a/native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/pubspec.yaml b/native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/pubspec.yaml new file mode 100644 index 0000000..1a1b444 --- /dev/null +++ b/native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/pubspec.yaml @@ -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 diff --git a/native/android/flet_vpn_bridge/src/vpn_native_bridge/__init__.py b/native/android/flet_vpn_bridge/src/vpn_native_bridge/__init__.py new file mode 100644 index 0000000..1e7eda2 --- /dev/null +++ b/native/android/flet_vpn_bridge/src/vpn_native_bridge/__init__.py @@ -0,0 +1,3 @@ +from .bridge import NativeBridge + +__all__ = ["NativeBridge"] diff --git a/native/android/flet_vpn_bridge/src/vpn_native_bridge/bridge.py b/native/android/flet_vpn_bridge/src/vpn_native_bridge/bridge.py new file mode 100644 index 0000000..b6a7916 --- /dev/null +++ b/native/android/flet_vpn_bridge/src/vpn_native_bridge/bridge.py @@ -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") diff --git a/native/android/libs/README.md b/native/android/libs/README.md new file mode 100644 index 0000000..79c652b --- /dev/null +++ b/native/android/libs/README.md @@ -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. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9e13333 --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/scripts/build-android.sh b/scripts/build-android.sh new file mode 100755 index 0000000..795024b --- /dev/null +++ b/scripts/build-android.sh @@ -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 diff --git a/scripts/build-desktop.sh b/scripts/build-desktop.sh new file mode 100755 index 0000000..a04219c --- /dev/null +++ b/scripts/build-desktop.sh @@ -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 diff --git a/scripts/fetch-engine-artifacts.sh b/scripts/fetch-engine-artifacts.sh new file mode 100755 index 0000000..a8aadb6 --- /dev/null +++ b/scripts/fetch-engine-artifacts.sh @@ -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@; 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 diff --git a/scripts/run-backend.sh b/scripts/run-backend.sh new file mode 100755 index 0000000..e9903d8 --- /dev/null +++ b/scripts/run-backend.sh @@ -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}" diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100755 index 0000000..7b62136 --- /dev/null +++ b/scripts/test.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." +uv run pytest +uv run --project backend pytest backend/tests diff --git a/tests/test_engine_manager.py b/tests/test_engine_manager.py new file mode 100644 index 0000000..a44997e --- /dev/null +++ b/tests/test_engine_manager.py @@ -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()) diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..288a977 --- /dev/null +++ b/tests/test_models.py @@ -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() diff --git a/tests/test_proxy.py b/tests/test_proxy.py new file mode 100644 index 0000000..df048b0 --- /dev/null +++ b/tests/test_proxy.py @@ -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 diff --git a/tests/test_serialization.py b/tests/test_serialization.py new file mode 100644 index 0000000..7e92012 --- /dev/null +++ b/tests/test_serialization.py @@ -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 + ) diff --git a/tests/test_subscription.py b/tests/test_subscription.py new file mode 100644 index 0000000..61045cc --- /dev/null +++ b/tests/test_subscription.py @@ -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") diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..0e2b0b3 --- /dev/null +++ b/uv.lock @@ -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" }, +]