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