Files
vpn/DESKTOP_VPN_PIPELINE.md
2026-04-20 01:34:03 +03:00

9.7 KiB
Raw Permalink Blame History

Pipeline реализации Flutter Desktop VPN (Windows + Linux) — исправленный

Ниже production-friendly пайплайн для desktop-приложения на Flutter с запуском sing-box.

0) Что исправлено относительно исходного варианта

  • Запуск процесса исправлен: используется путь к бинарнику, а не команда run как исполняемый файл.
  • Для Linux pkexec вызывается корректно: pkexec <binary> run -c <config>.
  • Убраны невалидные части Dart-кода (killPort) и добавлены недостающие импорты.
  • Добавлен шаг извлечения бинарника из assets в writable-директорию и chmod +x на Linux.
  • Исправлена модель прав: вместо небезопасного .pkla с ResultAny=yes — узкое polkit rule.
  • Уточнен runtime lifecycle (tray, hide-on-close, graceful stop, проверка exit code).

1) Инициализация проекта

flutter create singbox_vpn --platforms=windows,linux
cd singbox_vpn

pubspec.yaml:

name: singbox_vpn

dependencies:
  flutter:
    sdk: flutter
  flutter_riverpod: ^2.5.1
  tray_manager: ^0.2.1
  window_manager: ^0.3.8
  path_provider: ^2.1.3

flutter:
  assets:
    - assets/sing-box/linux-amd64/sing-box
    - assets/sing-box/windows-amd64/sing-box.exe

Примечания:

  • permission_handler для desktop здесь обычно не нужен.
  • Фиксируй версию sing-box явно, без latest в URL.

2) Скачивание и подготовка бинарников

mkdir -p assets/sing-box/linux-amd64 assets/sing-box/windows-amd64

# Linux x64
wget -O /tmp/sing-box-linux-amd64.tar.gz \
  https://github.com/SagerNet/sing-box/releases/download/v1.9.3/sing-box-1.9.3-linux-amd64.tar.gz
tar -xzf /tmp/sing-box-linux-amd64.tar.gz -C /tmp
cp /tmp/sing-box-1.9.3-linux-amd64/sing-box assets/sing-box/linux-amd64/sing-box
chmod +x assets/sing-box/linux-amd64/sing-box

# Windows x64
wget -O /tmp/sing-box-windows-amd64.zip \
  https://github.com/SagerNet/sing-box/releases/download/v1.9.3/sing-box-1.9.3-windows-amd64.zip
unzip -o /tmp/sing-box-windows-amd64.zip -d /tmp/sing-box-win
cp /tmp/sing-box-win/sing-box-1.9.3-windows-amd64/sing-box.exe assets/sing-box/windows-amd64/sing-box.exe

Рекомендуется:

  • проверять SHA256 перед копированием в assets;
  • хранить версии и checksum в отдельном tool/versions.json.

3) Структура проекта

lib/
├── main.dart
├── services/
│   ├── singbox_runtime.dart      # извлечение бинарника + пути
│   ├── singbox_service.dart      # запуск/остановка процесса
│   └── tray_service.dart
├── providers/
│   └── vpn_provider.dart
└── screens/
    └── home_screen.dart

4) Runtime: извлечение бинарника из assets

На старте приложения:

  1. Определить runtime-директорию: getApplicationSupportDirectory().
  2. Скопировать туда бинарник из assets, если отсутствует или версия изменилась.
  3. На Linux выставить chmod 755.
  4. Использовать только этот абсолютный путь в Process.start.

Минимальный пример (lib/services/singbox_runtime.dart):

import 'dart:io';
import 'package:flutter/services.dart';
import 'package:path_provider/path_provider.dart';

class SingBoxRuntime {
  Future<String> ensureBinary() async {
    final dir = await getApplicationSupportDirectory();
    final fileName = Platform.isWindows ? 'sing-box.exe' : 'sing-box';
    final outPath = '${dir.path}${Platform.pathSeparator}$fileName';
    final outFile = File(outPath);

    final assetPath = Platform.isWindows
        ? 'assets/sing-box/windows-amd64/sing-box.exe'
        : 'assets/sing-box/linux-amd64/sing-box';

    final bytes = await rootBundle.load(assetPath);
    await outFile.writeAsBytes(bytes.buffer.asUint8List(), flush: true);

    if (Platform.isLinux) {
      await Process.run('chmod', ['755', outPath]);
    }

    return outPath;
  }
}

5) Сервис запуска sing-box (исправленный)

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:path_provider/path_provider.dart';

class SingBoxService {
  Process? _process;
  final _logController = StreamController<String>.broadcast();

  Stream<String> get logs => _logController.stream;
  bool get isRunning => _process != null;

  Future<bool> start({
    required String binaryPath,
    required String configJson,
  }) async {
    if (_process != null) return true;

    final appDir = await getApplicationSupportDirectory();
    final configPath = '${appDir.path}${Platform.pathSeparator}config.json';
    await File(configPath).writeAsString(configJson, flush: true);

    List<String> command;
    if (Platform.isLinux) {
      command = ['pkexec', binaryPath, 'run', '-c', configPath];
    } else if (Platform.isWindows) {
      command = [binaryPath, 'run', '-c', configPath];
    } else {
      throw UnsupportedError('Only Windows and Linux are supported');
    }

    try {
      _process = await Process.start(command.first, command.sublist(1));

      _process!.stdout
          .transform(utf8.decoder)
          .listen((data) => _logController.add(data));
      _process!.stderr
          .transform(utf8.decoder)
          .listen((data) => _logController.add('ERROR: $data'));

      _process!.exitCode.then((code) {
        _logController.add('sing-box exited with code $code');
        _process = null;
      });

      return true;
    } catch (e) {
      _logController.add('Start failed: $e');
      _process = null;
      return false;
    }
  }

  Future<void> stop() async {
    final p = _process;
    if (p == null) return;
    p.kill(ProcessSignal.sigterm);
    await p.exitCode.timeout(const Duration(seconds: 3), onTimeout: () {
      p.kill(ProcessSignal.sigkill);
      return -1;
    });
    _process = null;
  }

  Future<void> dispose() async {
    await stop();
    await _logController.close();
  }
}

Важно для Windows elevation:

  • Process.start не поднимет права автоматически.
  • Нужна отдельная стратегия: requireAdministrator в runner.exe.manifest или сервис/launcher.

6) main.dart: tray + window lifecycle

Минимум:

  • import 'dart:io';
  • инициализация window_manager только на desktop;
  • setPreventClose(true) + обработка close в hide-to-tray;
  • действия tray (show, hide, quit) с корректным завершением VPN перед exit.

7) Linux Polkit (безопаснее)

Не используй широкое правило ResultAny=yes для всех команд.

Создай правило /etc/polkit-1/rules.d/10-singbox.rules:

polkit.addRule(function(action, subject) {
  if (action.id == "org.freedesktop.policykit.exec" &&
      action.lookup("program") == "/opt/singbox-vpn/sing-box" &&
      subject.isInGroup("singbox")) {
    return polkit.Result.AUTH_ADMIN;
  }
});

Примечания:

  • добавь пользователя в группу singbox;
  • используй фиксированный путь к бинарнику в установленном приложении.

8) Сборка

# Linux
flutter build linux --release

# Windows
flutter build windows --release

Не делай ручной cp после каждой сборки. Вместо этого:

  • либо оставь бинарник только в assets и извлекай при первом запуске (предпочтительно);
  • либо добавь копирование в platform build scripts (linux/CMakeLists.txt, windows/CMakeLists.txt).

9) Тестирование (smoke + lifecycle)

flutter run -d linux
flutter run -d windows

Проверка:

  1. Старт VPN запускает sing-box и пишет логи в UI.
  2. Linux: появляется polkit prompt при старте.
  3. Закрытие окна прячет приложение в tray, процесс жив.
  4. Stop завершает sing-box, повторный Start работает.
  5. Quit из tray останавливает процесс и завершает приложение.

10) Дистрибутивы

Linux (AppImage)

  • Собери корректный AppDir (desktop file, icon, executable).
  • Убедись, что sing-box либо в assets + runtime extraction, либо в ожидаемом пути для polkit-правила.

Windows (NSIS)

  • Добавь uninstall, ярлыки, версию, подпись и UAC-логику.
  • Если нужен запуск с elevated правами всегда — зафиксируй это в манифесте/инсталляторе.

11) Реалистичная оценка

  • PoC: 4-8 часов.
  • Стабильный desktop release: 2-5 дней (elevation, упаковка, smoke/regression).

12) Следующий шаг

  1. Внедрить SingBoxRuntime.ensureBinary() и запуск только через абсолютный путь.
  2. Добавить lifecycle tray/window и graceful shutdown.
  3. Проверить Linux polkit rule и выбранную Windows elevation-стратегию.