vpn/app/screens/vpn.py
2026-09-23 01:19:36 +00:00

188 lines
7.6 KiB
Python

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