188 lines
7.6 KiB
Python
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
|