merge: kolibri-migration into FullStack

# Conflicts:
#	lib/frontend/screens/profile/customization_section.dart
This commit is contained in:
klockky
2026-07-14 12:40:22 +03:00
30 changed files with 683 additions and 1875 deletions
+22
View File
@@ -0,0 +1,22 @@
name: Setup Rust
description: >
Install the Rust toolchain with platform targets and a build cache, so
cargokit can compile the kolibri native library during the Flutter build.
inputs:
targets:
description: Comma-separated rustup targets for the platform being built.
required: true
runs:
using: composite
steps:
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ inputs.targets }}
- name: Cache Rust build
uses: Swatinem/rust-cache@v2
with:
workspaces: third_party/kolibri/kolibri-dart/rust
+5
View File
@@ -41,6 +41,11 @@ jobs:
channel: 'stable'
cache: true
- name: Setup Rust
uses: ./.github/actions/setup-rust
with:
targets: aarch64-linux-android,armv7-linux-androideabi,x86_64-linux-android
- name: Get dependencies
run: flutter pub get
+5
View File
@@ -41,6 +41,11 @@ jobs:
channel: 'stable'
cache: true
- name: Setup Rust
uses: ./.github/actions/setup-rust
with:
targets: aarch64-linux-android,armv7-linux-androideabi,x86_64-linux-android
- name: Get dependencies
run: flutter pub get
+5
View File
@@ -37,6 +37,11 @@ jobs:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: 'stable'
- name: Setup Rust
uses: ./.github/actions/setup-rust
with:
targets: aarch64-apple-ios,aarch64-apple-ios-sim,x86_64-apple-ios
- name: Get dependencies
run: flutter pub get
+5
View File
@@ -42,6 +42,11 @@ jobs:
sudo apt-get update
sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev libsecret-1-dev libjsoncpp-dev libmpv-dev mpv libopus-dev libogg-dev
- name: Setup Rust
uses: ./.github/actions/setup-rust
with:
targets: x86_64-unknown-linux-gnu
- name: Get dependencies
run: flutter pub get
+5
View File
@@ -37,6 +37,11 @@ jobs:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: 'stable'
- name: Setup Rust
uses: ./.github/actions/setup-rust
with:
targets: aarch64-apple-darwin,x86_64-apple-darwin
- name: Install create-dmg
run: brew install create-dmg
+5
View File
@@ -37,6 +37,11 @@ jobs:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: 'stable'
- name: Setup Rust
uses: ./.github/actions/setup-rust
with:
targets: x86_64-pc-windows-msvc
- name: Get dependencies
run: flutter pub get
+5
View File
@@ -29,6 +29,11 @@ jobs:
channel: 'stable'
cache: true
- name: Setup Rust
uses: ./.github/actions/setup-rust
with:
targets: aarch64-linux-android,armv7-linux-androideabi,x86_64-linux-android
- name: Install dependencies
run: flutter pub get
+21 -4
View File
@@ -28,6 +28,11 @@ jobs:
channel: 'stable'
cache: true
- name: Setup Rust
uses: ./.github/actions/setup-rust
with:
targets: aarch64-linux-android,armv7-linux-androideabi,x86_64-linux-android
- name: Install dependencies
run: flutter pub get
@@ -66,7 +71,7 @@ jobs:
- name: Build Android APK
run: flutter build apk --release --flavor komet
web-linux:
linux:
runs-on: ubuntu-latest
steps:
- name: Checkout code
@@ -85,12 +90,14 @@ jobs:
sudo apt-get update
sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev libsecret-1-dev libjsoncpp-dev
- name: Setup Rust
uses: ./.github/actions/setup-rust
with:
targets: x86_64-unknown-linux-gnu
- name: Install dependencies
run: flutter pub get
- name: Build Web
run: flutter build web --release
- name: Build Linux
run: flutter build linux --release
@@ -108,6 +115,11 @@ jobs:
flutter-version: '3.44.3'
channel: 'stable'
- name: Setup Rust
uses: ./.github/actions/setup-rust
with:
targets: x86_64-pc-windows-msvc
- name: Install dependencies
run: flutter pub get
@@ -128,6 +140,11 @@ jobs:
flutter-version: '3.44.3'
channel: 'stable'
- name: Setup Rust
uses: ./.github/actions/setup-rust
with:
targets: aarch64-apple-ios,aarch64-apple-ios-sim,x86_64-apple-ios,aarch64-apple-darwin,x86_64-apple-darwin
- name: Install dependencies
run: flutter pub get
+21
View File
@@ -42,6 +42,11 @@ jobs:
channel: 'stable'
cache: true
- name: Setup Rust
uses: ./.github/actions/setup-rust
with:
targets: aarch64-linux-android,armv7-linux-androideabi,x86_64-linux-android
- name: Get dependencies
run: flutter pub get
@@ -110,6 +115,10 @@ jobs:
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: 'stable'
- name: Setup Rust
uses: ./.github/actions/setup-rust
with:
targets: x86_64-pc-windows-msvc
- name: Get dependencies
run: flutter pub get
- name: Build Windows
@@ -142,6 +151,10 @@ jobs:
run: |
sudo apt-get update
sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev libsecret-1-dev libjsoncpp-dev libmpv-dev mpv libopus-dev libogg-dev
- name: Setup Rust
uses: ./.github/actions/setup-rust
with:
targets: x86_64-unknown-linux-gnu
- name: Get dependencies
run: flutter pub get
- name: Build Linux
@@ -169,6 +182,10 @@ jobs:
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: 'stable'
- name: Setup Rust
uses: ./.github/actions/setup-rust
with:
targets: aarch64-apple-darwin,x86_64-apple-darwin
- name: Get dependencies
run: flutter pub get
- name: Fix opus/ogg libs for macOS
@@ -199,6 +216,10 @@ jobs:
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: 'stable'
- name: Setup Rust
uses: ./.github/actions/setup-rust
with:
targets: aarch64-apple-ios,aarch64-apple-ios-sim,x86_64-apple-ios
- name: Get dependencies
run: flutter pub get
- name: Build iOS (no codesign)
+21
View File
@@ -37,6 +37,11 @@ jobs:
channel: 'stable'
cache: true
- name: Setup Rust
uses: ./.github/actions/setup-rust
with:
targets: aarch64-linux-android,armv7-linux-androideabi,x86_64-linux-android
- name: Get dependencies
run: flutter pub get
@@ -105,6 +110,10 @@ jobs:
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: 'stable'
- name: Setup Rust
uses: ./.github/actions/setup-rust
with:
targets: x86_64-pc-windows-msvc
- name: Get dependencies
run: flutter pub get
- name: Build Windows
@@ -137,6 +146,10 @@ jobs:
run: |
sudo apt-get update
sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev libsecret-1-dev libjsoncpp-dev
- name: Setup Rust
uses: ./.github/actions/setup-rust
with:
targets: x86_64-unknown-linux-gnu
- name: Get dependencies
run: flutter pub get
- name: Build Linux
@@ -164,6 +177,10 @@ jobs:
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: 'stable'
- name: Setup Rust
uses: ./.github/actions/setup-rust
with:
targets: aarch64-apple-darwin,x86_64-apple-darwin
- name: Get dependencies
run: flutter pub get
- name: Build macOS
@@ -192,6 +209,10 @@ jobs:
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: 'stable'
- name: Setup Rust
uses: ./.github/actions/setup-rust
with:
targets: aarch64-apple-ios,aarch64-apple-ios-sim,x86_64-apple-ios
- name: Get dependencies
run: flutter pub get
- name: Build iOS (no codesign)
+3
View File
@@ -1,3 +1,6 @@
[submodule "third_party/rlottie"]
path = third_party/rlottie
url = https://github.com/Samsung/rlottie.git
[submodule "third_party/kolibri"]
path = third_party/kolibri
url = https://github.com/KometTeam/kolibri.git
+292 -245
View File
@@ -1,46 +1,50 @@
import 'dart:async';
import 'dart:typed_data';
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter_timezone/flutter_timezone.dart';
import 'package:kolibri/kolibri.dart';
import 'package:timezone/data/latest_all.dart' as tz;
import '../core/cache/self_presence.dart';
import '../core/config/config.dart';
import '../core/config/countries.dart';
import '../core/config/komet_settings.dart';
import '../core/config/proxy_config.dart';
import '../core/protocol/opcode_map.dart';
import '../core/protocol/packet.dart';
import '../core/storage/device_identity.dart';
import '../core/storage/spoofing_service.dart';
import '../core/transport/connection.dart';
import '../core/transport/dispatcher.dart';
import '../core/transport/receiver.dart';
import '../core/transport/sender.dart';
import '../core/transport/tls_config.dart';
import '../core/transport/traffic_monitor.dart';
import '../core/transport/vpn_bypass.dart';
import '../core/utils/debug_session_log.dart';
import '../core/utils/logger.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter_timezone/flutter_timezone.dart';
import 'package:timezone/data/latest_all.dart' as tz;
import 'dart:io';
enum SessionState { disconnected, connecting, connected, online }
/// Клиент API.
///
/// Подключение, хэндшейк, пинг, реконнект.
/// Тонкий адаптер над Rust-ядром [KolibriSession] (пакет kolibri): подключение,
/// хэндшейк, пинг и реконнект живут в ядре, здесь — оркестрация жизненного цикла
/// и сохранение прежнего интерфейса для модулей (Packet/пуши/стримы).
class Api {
final Connection _connection = Connection();
final PacketReceiver _receiver = PacketReceiver();
final PacketSender _sender = PacketSender();
KolibriSession? _session;
/// Роутер пушей (ответы на запросы ядро матчит само, диспетчер держим только
/// ради registerHandler/pushStream).
final PacketDispatcher _dispatcher = PacketDispatcher();
StreamSubscription<(int, Map<String, dynamic>)>? _pushSub;
SessionState _sessionState = SessionState.disconnected;
final _stateController = StreamController<SessionState>.broadcast();
final _sessionExpiredController =
StreamController<SessionExpiredException>.broadcast();
final _handshakeSuccessController = StreamController<String>.broadcast();
Map<dynamic, dynamic>? _userAgent;
final _errorController = StreamController<String>.broadcast();
Map<dynamic, dynamic>? _userAgent;
Map<dynamic, dynamic>? get userAgent => _userAgent;
int? _callsSeed;
@@ -49,6 +53,9 @@ class Api {
int? get callsSeed => _callsSeed;
String? get deviceId => _deviceId;
/// Сырой доступ к сессии ядра — для медиа-загрузок (data-plane).
KolibriSession? get session => _session;
String? spoofScope;
static bool _tzInitialized = false;
@@ -63,27 +70,27 @@ class Api {
_sessionExpiredController.stream;
Stream<String> get handshakeSuccessStream =>
_handshakeSuccessController.stream;
Stream<String> get errorStream => _dispatcher.errorStream;
Stream<String> get errorStream => _errorController.stream;
SessionState get state => _sessionState;
StreamSubscription<Uint8List>? _dataSubscription;
StreamSubscription<SocketState>? _socketStateSubscription;
Timer? _pingTimer;
Timer? _livenessTimer;
Timer? _reconnectTimer;
Timer? _connectWatchdog;
int _connectGen = 0;
int _reconnectAttempts = 0;
bool _autoReconnect = false;
int _sessionEpoch = 0;
int _seq = 0;
bool? _lastInteractive;
static const Duration _connectWatchdogTimeout = Duration(seconds: 75);
static const Duration _shouldArmTimeout = Duration(seconds: 5);
static const Duration _endpointTimeout = Duration(seconds: 5);
static const Duration _livenessInterval = Duration(seconds: 5);
int get sessionEpoch => _sessionEpoch;
/// Залипает на время сессии: VPN-путь не сработал — идём мимо туннеля.
bool _bypassActive = false;
int _nextSeq() => _seq = (_seq + 1) & 0xFFFF;
// Публичное API
@@ -100,31 +107,17 @@ class Api {
_armConnectWatchdog(gen);
try {
_dataSubscription = _connection.dataStream.listen(_onDataReceived);
_socketStateSubscription = _connection.stateStream.listen((socketState) {
if (socketState == SocketState.disconnected &&
_sessionState != SessionState.disconnected) {
_onDisconnected();
}
});
bool bypassArmed;
bool useBypass;
try {
bypassArmed = await VpnBypassService.instance.shouldArm().timeout(
useBypass = await VpnBypassService.instance.shouldArm().timeout(
_shouldArmTimeout,
);
} catch (e) {
logger.w('connect: shouldArm завис/упал ($e) — без обхода VPN');
bypassArmed = false;
useBypass = false;
}
if (gen != _connectGen) return;
if (!bypassArmed) _bypassActive = false;
final useBypass = _bypassActive && bypassArmed;
final attemptTimeout = bypassArmed && !useBypass
? const Duration(seconds: 8)
: null;
({String host, int port}) endpoint;
try {
endpoint = await ServerConfig.loadEndpoint().timeout(_endpointTimeout);
@@ -137,80 +130,66 @@ class Api {
}
if (gen != _connectGen) return;
final options = await _buildSessionOptions(endpoint);
if (gen != _connectGen) return;
logger.i(
'connect: endpoint ${endpoint.host}:${endpoint.port}, bypass=$useBypass',
);
try {
await _connection.connect(
endpoint.host,
endpoint.port,
bypassVpn: useBypass,
timeout: attemptTimeout,
);
} catch (e) {
if (gen != _connectGen) return;
await _handleConnectFailure(
e,
phase: 'Не удалось подключиться',
bypassArmed: bypassArmed,
useBypass: useBypass,
bypassWhy: 'подключение не удалось',
);
return;
if (useBypass) {
try {
await VpnBypassService.instance.bind();
} catch (e) {
logger.w('connect: VPN bind не удался ($e)');
}
}
if (gen != _connectGen) return;
final session = options;
_session = session;
_pushSub = session.pushesMap().listen(_onPush);
TrafficMonitor.instance.recordEvent(
'connect',
endpoint: '${endpoint.host}:${endpoint.port}',
);
_setSessionState(SessionState.connected);
_reconnectAttempts = 0;
HandshakeInfo info;
try {
logger.i('connect: сокет готов, отправляю хэндшейк');
final response = await sendHandshake();
if (gen != _connectGen) return;
if (response.isOk) {
_callsSeed = response.payload['callsSeed'] as int?;
_registrationCountries = _parseRegistrationCountries(
response.payload,
);
_sessionState = SessionState.online;
_sessionEpoch++;
_cancelConnectWatchdog();
_startPinging();
logger.i('Сессия онлайн, хэндшейк ок');
if (_onReconnectCallback != null) {
try {
await _onReconnectCallback!();
} catch (e) {
logger.w('Авто-логин при хэндшейке не удался: $e');
}
}
if (_sessionState == SessionState.online) {
_stateController.add(SessionState.online);
_handshakeSuccessController.add(
response.payload['device_name'] as String? ?? 'Unknown',
);
}
} else {
logger.e('Хэндшейк отклонён: ${response.payload}');
await _handleConnectFailure(
StateError('хэндшейк отклонён сервером'),
phase: 'Хэндшейк отклонён',
bypassArmed: bypassArmed,
useBypass: useBypass,
bypassWhy: 'хэндшейк отклонён',
disconnectSocket: true,
);
}
info = await session.connect();
} catch (e) {
if (gen != _connectGen) return;
await _handleConnectFailure(
e,
phase: 'Ошибка хэндшейка',
bypassArmed: bypassArmed,
useBypass: useBypass,
bypassWhy: 'хэндшейк не прошёл',
disconnectSocket: true,
);
await _handleConnectFailure(e, phase: 'Ошибка хэндшейка');
return;
} finally {
if (useBypass) {
try {
await VpnBypassService.instance.restoreDefault();
} catch (_) {}
}
}
if (gen != _connectGen) return;
_callsSeed = info.callsSeed?.toInt();
_registrationCountries = _parseRegistrationCountries(info.payloadMap);
_sessionState = SessionState.online;
_sessionEpoch++;
_cancelConnectWatchdog();
_startLiveness();
logger.i('Сессия онлайн, хэндшейк ок');
if (_onReconnectCallback != null) {
try {
await _onReconnectCallback!();
} catch (e) {
logger.w('Авто-логин при хэндшейке не удался: $e');
}
}
if (_sessionState == SessionState.online) {
_stateController.add(SessionState.online);
_handshakeSuccessController.add(info.deviceName ?? 'Unknown');
}
} catch (e, st) {
logger.e('connect: непредвиденная ошибка: $e\n$st');
@@ -244,9 +223,6 @@ class Api {
_connectGen++;
_cancelConnectWatchdog();
_cleanup();
try {
await _connection.disconnect();
} catch (_) {}
_setSessionState(SessionState.disconnected);
if (_autoReconnect) _scheduleReconnect();
}
@@ -254,37 +230,22 @@ class Api {
Future<void> _handleConnectFailure(
Object error, {
required String phase,
required bool bypassArmed,
required bool useBypass,
required String bypassWhy,
bool disconnectSocket = false,
}) async {
logger.e('$phase: $error');
_cancelConnectWatchdog();
if (_sessionState != SessionState.disconnected) {
_cleanup();
if (disconnectSocket) await _connection.disconnect();
_setSessionState(SessionState.disconnected);
_armBypassIfPossible(bypassArmed, useBypass, bypassWhy);
_scheduleReconnect();
}
}
void _armBypassIfPossible(bool armed, bool alreadyBypassing, String why) {
if (armed && !alreadyBypassing && !_bypassActive) {
_bypassActive = true;
logger.w('VPN bypass: $why — следующая попытка мимо VPN');
}
}
/// Отключается без автореконнекта.
Future<void> disconnect() async {
_autoReconnect = false;
_bypassActive = false;
_connectGen++;
_reconnectTimer?.cancel();
_cleanup();
await _connection.disconnect();
_setSessionState(SessionState.disconnected);
}
@@ -303,7 +264,114 @@ class Api {
}
}
Future<Packet> sendHandshake() async {
/// Отправляет запрос и ждёт ответ от сервера.
Future<Packet> sendRequest(int opcode, Map<dynamic, dynamic> payload) async {
final session = _session;
if (session == null) {
throw StateError('Нет соединения (${Opcode.name(opcode)})');
}
final seq = _nextSeq();
DebugSessionLog.instance.recordRequest(opcode, seq, payload);
TrafficMonitor.instance.recordOutgoing(opcode, payload, seq, 0);
final KolibriResponse resp;
try {
resp = await session
.requestMapFull(opcode, Map<String, dynamic>.from(payload))
.timeout(
ServerConfig.requestTimeout,
onTimeout: () =>
throw TimeoutException('${Opcode.name(opcode)} таймаут'),
);
} catch (e, st) {
DebugSessionLog.instance.recordError(seq, e);
Error.throwWithStackTrace(e, st);
}
final packet = Packet(
cmd: resp.cmd,
seq: seq,
opcode: resp.opcode,
payload: resp.payload,
);
DebugSessionLog.instance.recordResponse(seq, packet.cmd, packet.payload);
TrafficMonitor.instance.recordIncoming(packet, 0);
if (packet.isError) {
if (isSessionExpiredPayload(packet.payload)) {
final ex = SessionExpiredException(
messageFromErrorPayload(packet.payload),
);
_sessionExpiredController.add(ex);
DebugSessionLog.instance.recordError(seq, ex);
throw ex;
}
final text = _serverErrorText(packet.payload);
if (text != null) _errorController.add(text);
final err = PacketError(
messageFromErrorPayload(packet.payload),
errorKey: resp.errorKey,
);
DebugSessionLog.instance.recordError(seq, err);
throw err;
}
return packet;
}
Future<Map<dynamic, dynamic>?> sendRequestMap(
int opcode,
Map<dynamic, dynamic> payload,
) async {
final response = await sendRequest(opcode, payload);
if (!response.isOk || response.payload is! Map) return null;
return response.payload as Map<dynamic, dynamic>;
}
Future<bool> sendRequestOk(int opcode, Map<dynamic, dynamic> payload) async {
final response = await sendRequest(opcode, payload);
return response.isOk;
}
Future<Packet> sendRequestOrThrow(
int opcode,
Map<dynamic, dynamic> payload,
) async {
final response = await sendRequest(opcode, payload);
throwIfPacketError(response);
return response;
}
/// Вешает обработчик на пуши с указанным опкодом.
void registerPushHandler(int opcode, void Function(Packet) handler) {
_dispatcher.registerHandler(opcode, handler);
}
/// Снимает обработчик пушей с указанного опкода.
void unregisterPushHandler(int opcode) {
_dispatcher.unregisterHandler(opcode);
}
/// Стрим всех входящих пушей от сервера.
Stream<Packet> get pushStream => _dispatcher.pushStream;
Future<void> dispose() async {
_autoReconnect = false;
_reconnectTimer?.cancel();
_cleanup();
_dispatcher.dispose();
await _stateController.close();
await _sessionExpiredController.close();
await _handshakeSuccessController.close();
await _errorController.close();
}
// Внутрянка
/// Строит устройство-поля и создаёт сессию ядра. Заодно заполняет
/// [_userAgent] и [_deviceId] для геттеров.
Future<KolibriSession> _buildSessionOptions(
({String host, int port}) endpoint,
) async {
final deviceInfo = DeviceInfoPlugin();
String deviceType = 'ANDROID';
@@ -404,95 +472,57 @@ class Api {
'deviceName': deviceName,
'deviceLocale': deviceLocale,
};
_deviceId = deviceId;
final payload = <dynamic, dynamic>{
'mt_instanceid': instanceId,
'userAgent': _userAgent,
'clientSessionId': clientSessionId,
'deviceId': deviceId,
};
final insecureTls = await TlsConfig.isInsecureAllowed();
final proxy = await _buildProxyUrl();
return sendRequest(Opcode.sessionInit, payload);
return openSession(
host: endpoint.host,
port: endpoint.port,
deviceId: deviceId,
instanceId: instanceId,
appVersion: appVersion,
buildNumber: buildNumber,
deviceType: deviceType,
osVersion: osVersion,
timezone: timezone,
screen: screen,
pushDeviceType: pushDeviceType,
arch: architecture,
locale: locale,
deviceName: deviceName,
deviceLocale: deviceLocale,
clientSessionId: clientSessionId,
pingIntervalSecs: ServerConfig.pingInterval.inSeconds,
pingInteractive: !KometSettings.ghostMode.value,
autoReconnect: false,
insecureTls: insecureTls,
proxy: proxy,
);
}
/// Отправляет запрос и ждёт ответ от сервера.
Future<Packet> sendRequest(int opcode, Map<dynamic, dynamic> payload) {
final seq = _sender.send(_connection, opcode, payload);
DebugSessionLog.instance.recordRequest(opcode, seq, payload);
return _dispatcher
.registerPending(seq)
.timeout(
ServerConfig.requestTimeout,
onTimeout: () =>
throw TimeoutException('${Opcode.name(opcode)} таймаут'),
)
.then(
(packet) {
DebugSessionLog.instance.recordResponse(
seq,
packet.cmd,
packet.payload,
);
return packet;
},
onError: (Object e, StackTrace st) {
DebugSessionLog.instance.recordError(seq, e);
Error.throwWithStackTrace(e, st);
},
);
static Future<String?> _buildProxyUrl() async {
final p = await ProxyConfig.load();
if (!p.isEnabled) return null;
final scheme = p.type == ProxyType.socks5 ? 'socks5h' : 'http';
final auth = p.hasCredentials
? '${Uri.encodeComponent(p.username!)}:'
'${Uri.encodeComponent(p.password!)}@'
: '';
return '$scheme://$auth${p.host}:${p.port}';
}
Future<Map<dynamic, dynamic>?> sendRequestMap(
int opcode,
Map<dynamic, dynamic> payload,
) async {
final response = await sendRequest(opcode, payload);
if (!response.isOk || response.payload is! Map) return null;
return response.payload as Map<dynamic, dynamic>;
void _onPush((int, Map<String, dynamic>) event) {
final packet = Packet(
cmd: CmdType.push,
opcode: event.$1,
payload: event.$2,
);
TrafficMonitor.instance.recordIncoming(packet, 0);
_dispatcher.dispatch(packet);
}
Future<bool> sendRequestOk(int opcode, Map<dynamic, dynamic> payload) async {
final response = await sendRequest(opcode, payload);
return response.isOk;
}
Future<Packet> sendRequestOrThrow(
int opcode,
Map<dynamic, dynamic> payload,
) async {
final response = await sendRequest(opcode, payload);
throwIfPacketError(response);
return response;
}
/// Вешает обработчик на пуши с указанным опкодом.
void registerPushHandler(int opcode, void Function(Packet) handler) {
_dispatcher.registerHandler(opcode, handler);
}
/// Снимает обработчик пушей с указанного опкода.
void unregisterPushHandler(int opcode) {
_dispatcher.unregisterHandler(opcode);
}
/// Стрим всех входящих пушей от сервера.
Stream<Packet> get pushStream => _dispatcher.pushStream;
Future<void> dispose() async {
_autoReconnect = false;
_reconnectTimer?.cancel();
_cleanup();
_dispatcher.dispose();
await _connection.dispose();
await _stateController.close();
await _sessionExpiredController.close();
await _handshakeSuccessController.close();
}
// Внутрянка
void _setSessionState(SessionState state) {
if (_sessionState == state) return;
_sessionState = state;
@@ -500,35 +530,6 @@ class Api {
logger.i('Сессия: ${state.name}');
}
Future<void> _onDataReceived(Uint8List data) async {
final List<Uint8List> rawPackets;
try {
rawPackets = _receiver.feed(data);
} on ReceiverOverflowException catch (e) {
logger.e('$e — форсируем реконнект');
if (_sessionState != SessionState.disconnected) {
unawaited(_forceReconnect());
}
return;
}
for (final raw in rawPackets) {
final Packet packet;
try {
packet = await unpackPacket(raw);
} catch (e) {
logger.e('PacketReceiver: ошибка распаковки: $e');
continue;
}
TrafficMonitor.instance.recordIncoming(packet, raw.length);
if (packet.isError && isSessionExpiredPayload(packet.payload)) {
_sessionExpiredController.add(
SessionExpiredException(messageFromErrorPayload(packet.payload)),
);
}
_dispatcher.dispatch(packet);
}
}
void _onDisconnected() {
_connectGen++;
_cleanup();
@@ -536,13 +537,18 @@ class Api {
if (_autoReconnect) _scheduleReconnect();
}
/// Пробный запрос-пинг: если не ответил — форсируем реконнект.
Future<void> _probeLiveness() async {
if (_sessionState != SessionState.online) return;
final session = _session;
if (session == null) return;
final epoch = _sessionEpoch;
try {
await sendRequest(Opcode.ping, {
'interactive': !KometSettings.ghostMode.value,
}).timeout(const Duration(seconds: 6));
await session
.requestMapFull(Opcode.ping, {
'interactive': !KometSettings.ghostMode.value,
})
.timeout(const Duration(seconds: 6));
} catch (_) {
if (_sessionEpoch != epoch || _sessionState != SessionState.online) {
return;
@@ -555,7 +561,6 @@ class Api {
Future<void> _forceReconnect() async {
_connectGen++;
_cleanup();
await _connection.disconnect();
_reconnectAttempts = 0;
_reconnectTimer?.cancel();
_setSessionState(SessionState.disconnected);
@@ -564,12 +569,18 @@ class Api {
void _cleanup() {
_cancelConnectWatchdog();
_pingTimer?.cancel();
_dataSubscription?.cancel();
_socketStateSubscription?.cancel();
_dataSubscription = null;
_socketStateSubscription = null;
_receiver.reset();
_livenessTimer?.cancel();
_livenessTimer = null;
_pushSub?.cancel();
_pushSub = null;
_lastInteractive = null;
final session = _session;
_session = null;
if (session != null) {
try {
session.disconnect();
} catch (_) {}
}
_dispatcher.clearPending();
_handshakeSuccessController.add('disconnected');
}
@@ -584,17 +595,44 @@ class Api {
_onReconnectCallback = callback;
}
void _startPinging() {
_pingTimer?.cancel();
sendPing(interactive: !KometSettings.ghostMode.value);
_pingTimer = Timer.periodic(ServerConfig.pingInterval, (_) {
sendPing(interactive: !KometSettings.ghostMode.value);
});
/// Поллит состояние ядра (стрима состояний нет) — детект разрыва, плюс
/// синхронизация interactive-флага пинга и присутствия.
void _startLiveness() {
_livenessTimer?.cancel();
_lastInteractive = !KometSettings.ghostMode.value;
_livenessTimer = Timer.periodic(_livenessInterval, (_) => _tickLiveness());
}
void _tickLiveness() {
final session = _session;
if (session == null || _sessionState != SessionState.online) return;
final st = session.state();
if (st != 'online' && st != 'connected') {
logger.w('kolibri сессия "$st" — реконнект');
_onDisconnected();
return;
}
final interactive = !KometSettings.ghostMode.value;
if (interactive != _lastInteractive) {
_lastInteractive = interactive;
try {
session.setPingInteractive(interactive: interactive);
} catch (_) {}
}
if (interactive) {
SelfPresence.markOnline();
} else {
SelfPresence.markOfflineFromPing();
}
}
void sendPing({required bool interactive}) {
if (_connection.isConnected) {
_sender.send(_connection, Opcode.ping, {'interactive': interactive});
final session = _session;
if (session != null && _sessionState == SessionState.online) {
try {
session.setPingInteractive(interactive: interactive);
} catch (_) {}
_lastInteractive = interactive;
if (interactive) {
SelfPresence.markOnline();
} else {
@@ -608,6 +646,15 @@ class Api {
return v.substring(v.indexOf('_') + 1, v.length - 1);
}
static String? _serverErrorText(dynamic payload) {
if (payload is! Map) return null;
for (final key in ['localizedMessage', 'title']) {
final v = payload[key];
if (v is String && v.trim().isNotEmpty) return v.trim();
}
return null;
}
static List<CountryName>? _parseRegistrationCountries(dynamic payload) {
if (payload is! Map) return null;
final raw = payload['reg-country-code'];
+126 -564
View File
@@ -3,11 +3,10 @@ import 'dart:convert' show jsonDecode, utf8;
import 'dart:io';
import 'dart:typed_data';
import 'package:kolibri/kolibri.dart' as kb;
import '../api.dart';
import '../../core/config/proxy_config.dart';
import '../../core/protocol/opcode_map.dart';
import '../../core/transport/proxy_connector.dart';
import '../../core/transport/tls_config.dart';
import '../../core/utils/logger.dart';
import 'messages.dart';
@@ -41,10 +40,10 @@ class UploadError extends UploadEvent {
const UploadError(this.message);
}
/// Оркестратор медиа-загрузок: control-plane (URL, отправка сообщения) идёт
/// обычными опкодами, data-plane (заливка на CDN) — через Rust-ядро kolibri,
/// которое стримит файл с диска (не держит его целиком в памяти).
class FileUploader {
static const String _userAgentHeader =
'OKMessages/26.14.1 (Android 11; TECNO MOBILE LIMITED TECNO LE7n; xxhdpi 480dpi 1080x2208)';
final Api api;
final MessagesModule messages;
@@ -62,13 +61,11 @@ class FileUploader {
}) {
final ctrl = StreamController<UploadEvent>();
var cancelled = false;
Socket? socket;
StreamSubscription<kb.UploadEvent>? sub;
ctrl.onCancel = () {
cancelled = true;
try {
socket?.destroy();
} catch (_) {}
sub?.cancel();
};
Future<void> run() async {
@@ -80,6 +77,12 @@ class FileUploader {
return;
}
final session = api.session;
if (session == null) {
ctrl.add(const UploadError('no_session'));
return;
}
unawaited(() async {
try {
await api.sendRequest(Opcode.msgTyping, {
@@ -89,27 +92,50 @@ class FileUploader {
} catch (_) {}
}());
final uri = Uri.parse(info.url);
final result = await _sendHttpRequest(
uri,
method: 'POST',
headers: _buildUploadHeaders(uri, filename, totalSize),
bodyStream: file.openRead(),
progressTotal: totalSize,
onProgress: (sent, total) {
if (!cancelled) ctrl.add(UploadProgress(sent: sent, total: total));
},
progressThrottle: progressThrottle,
autoForceAfter: autoForceAfter,
timeout: overallTimeout,
onSocketReady: (s) => socket = s,
shouldAbort: () => cancelled,
);
var status = 0;
String? error;
final done = Completer<void>();
sub =
session
.uploadFilePath(
url: info.url,
path: file.path,
filename: filename,
connection: 'close',
)
.listen(
(e) {
switch (e) {
case kb.UploadEvent_Progress(:final sent, :final total):
ctrl.add(
UploadProgress(
sent: sent.toInt(),
total: total.toInt(),
),
);
case kb.UploadEvent_Done(status: final s):
status = s;
case kb.UploadEvent_Error(:final message):
error = message;
}
},
onError: (Object err) {
error = err.toString();
if (!done.isCompleted) done.complete();
},
onDone: () {
if (!done.isCompleted) done.complete();
},
cancelOnError: true,
);
await done.future;
if (cancelled) return;
final statusCode = result?.$1 ?? 0;
if (statusCode != 200 && statusCode != 0) {
ctrl.add(UploadError('http_$statusCode'));
if (error != null) {
ctrl.add(UploadError(error!));
return;
}
if (status != 200 && status != 0) {
ctrl.add(UploadError('http_$status'));
return;
}
@@ -137,9 +163,6 @@ class FileUploader {
} catch (e) {
if (!cancelled) ctrl.add(UploadError(e.toString()));
} finally {
try {
socket?.destroy();
} catch (_) {}
await ctrl.close();
}
}
@@ -155,133 +178,53 @@ class FileUploader {
Duration overallTimeout = const Duration(minutes: 5),
Duration progressThrottle = const Duration(milliseconds: 16),
}) async {
final session = api.session;
if (session == null) return false;
try {
final total = await file.length();
if (total <= 0) return false;
final filename = _syntheticFilename();
final result = await _sendHttpRequest(
uri,
method: 'POST',
headers: _buildUploadHeaders(
uri,
filename,
total,
final result = await _consume(
session.uploadFilePath(
url: uri.toString(),
path: file.path,
filename: _syntheticFilename(),
contentType: 'application/octet-stream',
connection: 'close',
),
bodyStream: file.openRead(),
progressTotal: total,
onProgress: onProgress,
progressThrottle: progressThrottle,
timeout: overallTimeout,
);
final statusCode = result?.$1 ?? 0;
final respBody = result?.$2 ?? '';
logger.w(
'uploadMediaFile: status=$statusCode total=$total '
'host=${uri.host} body=${respBody.length > 200 ? respBody.substring(0, 200) : respBody}',
);
if (result.error != null) {
logger.w('uploadMediaFile: ${result.error}');
return false;
}
final respBody = utf8.decode(result.body, allowMalformed: true);
final hasError =
respBody.contains('error_msg') || respBody.contains('error_code');
return statusCode == 200 && !hasError;
return result.status == 200 && !hasError;
} catch (e) {
logger.w('uploadMediaFile: $e');
return false;
}
}
Future<Socket> _openSocket(Uri uri) async {
final proxySettings = await ProxyConfig.load();
final base = proxySettings.isEnabled
? await ProxyConnector(proxySettings).connect(uri.host, uri.port)
: await Socket.connect(uri.host, uri.port);
if (uri.scheme != 'https') return base;
final allowInsecure = await TlsConfig.isInsecureAllowed();
if (allowInsecure) {
logger.w(
'TLS: проверка сертификата отключена (дебаг) — загрузка уязвима к MitM',
);
return SecureSocket.secure(
base,
host: uri.host,
onBadCertificate: (_) => true,
);
}
return SecureSocket.secure(base, host: uri.host);
}
String _syntheticFilename() =>
(DateTime.now().microsecondsSinceEpoch & 0x7FFFFFFF).toString();
String _multipartBoundary() =>
'----KometBoundary${DateTime.now().microsecondsSinceEpoch}';
Map<String, String> _buildUploadHeaders(
Uri uri,
String filename,
int total, {
String contentType = 'application/x-binary; charset=x-user-defined',
String connection = 'keep-alive',
}) {
return {
'Host': uri.host,
'Content-Type': contentType,
'Content-Disposition': 'attachment; filename=$filename',
'Connection': connection,
'User-Agent': Uri.encodeComponent(_userAgentHeader),
'Content-Range': 'bytes 0-${total - 1}/$total',
'Content-Length': '$total',
};
}
Future<String?> uploadImage(
Uri uri,
Uint8List bytes, {
String filename = 'avatar.jpg',
}) async {
final session = api.session;
if (session == null) return null;
try {
final boundary = _multipartBoundary();
final preamble = utf8.encode(
'--$boundary\r\n'
'Content-Disposition: form-data; name="file"; filename="$filename"\r\n'
'Content-Type: ${_contentTypeForFilename(filename)}\r\n'
'\r\n',
);
final epilogue = utf8.encode('\r\n--$boundary--\r\n');
final response = await _sendHttpRequest(
uri,
method: 'POST',
headers: _buildMultipartHeaders(
uri,
preamble.length + bytes.length + epilogue.length,
boundary: boundary,
final result = await _consume(
session.uploadPhoto(
url: uri.toString(),
data: bytes,
filename: filename,
),
prefixBytes: preamble,
bodyStream: Stream.value(bytes),
suffixBytes: epilogue,
timeout: const Duration(minutes: 2),
);
if (response == null) {
if (result.error != null || result.status != 200) {
logger.w('uploadImage: status=${result.status} error=${result.error}');
return null;
}
final (status, body) = response;
if (status != 200) {
logger.w(
'uploadImage: status=$status body=${body.length > 200 ? '${body.substring(0, 200)}' : body}',
);
return null;
}
final token = _parsePhotoToken(body);
if (token == null) {
logger.w(
'uploadImage: photoToken not found in body=${body.length > 200 ? '${body.substring(0, 200)}' : body}',
);
}
return token;
return _parsePhotoToken(utf8.decode(result.body, allowMalformed: true));
} catch (e) {
logger.w('uploadImage: $e');
return null;
@@ -295,41 +238,22 @@ class FileUploader {
void Function(int sent, int total)? onProgress,
Duration progressThrottle = const Duration(milliseconds: 16),
}) async {
final session = api.session;
if (session == null) return null;
try {
final fileLength = await file.length();
final boundary = _multipartBoundary();
final preamble = utf8.encode(
'--$boundary\r\n'
'Content-Disposition: form-data; name="file"; filename="$filename"\r\n'
'Content-Type: ${_contentTypeForFilename(filename)}\r\n'
'\r\n',
);
final epilogue = utf8.encode('\r\n--$boundary--\r\n');
final response = await _sendHttpRequest(
uri,
method: 'POST',
headers: _buildMultipartHeaders(
uri,
preamble.length + fileLength + epilogue.length,
boundary: boundary,
final result = await _consume(
session.uploadPhotoPath(
url: uri.toString(),
path: file.path,
filename: filename,
),
prefixBytes: preamble,
bodyStream: file.openRead(),
suffixBytes: epilogue,
progressTotal: fileLength,
onProgress: onProgress,
progressThrottle: progressThrottle,
timeout: const Duration(minutes: 2),
);
if (response == null) return null;
final (status, responseBody) = response;
if (status != 200) {
logger.w('uploadPhoto: status=$status');
if (result.error != null || result.status != 200) {
logger.w('uploadPhoto: status=${result.status} error=${result.error}');
return null;
}
return _parsePhotoToken(responseBody);
return _parsePhotoToken(utf8.decode(result.body, allowMalformed: true));
} catch (e) {
logger.w('uploadPhoto: $e');
return null;
@@ -344,342 +268,55 @@ class FileUploader {
int concurrency = 4,
Duration overallTimeout = const Duration(minutes: 30),
}) async {
final total = await file.length();
if (total <= 0) return false;
final fileName = _syntheticFilename();
final handshake = await _okCdnRequest(
uri,
method: 'GET',
fileName: fileName,
timeout: const Duration(seconds: 30),
);
if (handshake == null || handshake.$1 != 200) return false;
var startOffset = 0;
final resumed = int.tryParse(handshake.$2.trim());
if (resumed != null && resumed > 0 && resumed <= total) {
startOffset = resumed;
}
final ranges = <(int, int)>[];
for (var o = startOffset; o < total; o += chunkSize) {
ranges.add((o, o + chunkSize < total ? o + chunkSize : total));
}
if (ranges.isEmpty) return true;
var nextIndex = 0;
var sent = startOffset;
var failed = false;
Future<void> worker() async {
while (!failed) {
final i = nextIndex++;
if (i >= ranges.length) return;
final (start, end) = ranges[i];
final bytes = await _readRange(file, start, end);
final resp = await _okCdnRequest(
uri,
method: 'POST',
fileName: fileName,
body: bytes,
contentRange: 'bytes $start-${end - 1}/$total',
timeout: overallTimeout,
);
if (resp == null || (resp.$1 != 200 && resp.$1 != 201)) {
logger.w('uploadVideoFile: chunk status=${resp?.$1}');
failed = true;
return;
}
sent += end - start;
onProgress?.call(sent, total);
}
}
final workerCount = concurrency < ranges.length
? concurrency
: ranges.length;
await Future.wait(List.generate(workerCount, (_) => worker()));
return !failed;
}
Future<Uint8List> _readRange(File file, int start, int end) async {
final builder = BytesBuilder(copy: false);
await for (final chunk in file.openRead(start, end)) {
builder.add(chunk);
}
return builder.takeBytes();
}
Future<(int, String)?> _okCdnRequest(
Uri uri, {
required String method,
required String fileName,
Uint8List? body,
String? contentRange,
required Duration timeout,
}) async {
final session = api.session;
if (session == null) return false;
try {
final headers = {
'Host': uri.host,
'Content-Type': 'application/x-binary; charset=x-user-defined',
'Content-Disposition': 'attachment; fileName="$fileName"',
'Content-Range': ?contentRange,
'Content-Length': '${body?.length ?? 0}',
'X-Uploading-Mode': 'parallel',
'Connection': 'close',
};
return await _sendHttpRequest(
uri,
method: method,
headers: headers,
prefixBytes: body,
timeout: timeout,
final result = await _consume(
session.uploadVideoPath(
url: uri.toString(),
path: file.path,
chunkSize: chunkSize,
concurrency: concurrency,
),
onProgress: onProgress,
);
if (result.error != null || result.status != 200) {
logger.w(
'uploadVideoFile: status=${result.status} error=${result.error}',
);
}
return result.error == null && result.status == 200;
} catch (e) {
logger.w('_okCdnRequest($method): $e');
return null;
logger.w('uploadVideoFile: $e');
return false;
}
}
Map<String, String> _buildMultipartHeaders(
Uri uri,
int total, {
required String boundary,
}) {
return {
'Host': uri.host,
'Content-Type': 'multipart/form-data; boundary=$boundary',
'Content-Length': '$total',
'Connection': 'keep-alive',
'User-Agent': Uri.encodeComponent(_userAgentHeader),
};
}
Future<(int, String)?> _sendHttpRequest(
Uri uri, {
required String method,
required Map<String, String> headers,
List<int>? prefixBytes,
Stream<List<int>>? bodyStream,
List<int>? suffixBytes,
int? progressTotal,
/// Прогоняет стрим ядра до конца, форвардит прогресс, отдаёт итог.
Future<({int status, Uint8List body, String? error})> _consume(
Stream<kb.UploadEvent> stream, {
void Function(int sent, int total)? onProgress,
Duration progressThrottle = const Duration(milliseconds: 16),
Duration? autoForceAfter,
required Duration timeout,
void Function(Socket socket)? onSocketReady,
bool Function()? shouldAbort,
}) async {
final socket = await _openSocket(uri);
onSocketReady?.call(socket);
try {
if (shouldAbort?.call() ?? false) return null;
_writeRequestHeaders(socket, uri, method, headers);
if (prefixBytes != null && prefixBytes.isNotEmpty) {
socket.add(prefixBytes);
}
if (bodyStream != null) {
final stream = (onProgress != null && progressTotal != null)
? _withProgress(
bodyStream,
progressTotal,
onProgress,
throttle: progressThrottle,
)
: bodyStream;
await socket.addStream(stream);
}
if (suffixBytes != null && suffixBytes.isNotEmpty) {
socket.add(suffixBytes);
}
await socket.flush();
if (onProgress != null && progressTotal != null) {
onProgress(progressTotal, progressTotal);
}
if (shouldAbort?.call() ?? false) return null;
if (autoForceAfter != null) {
final status = await _readResponse(
socket,
autoForceAfter: autoForceAfter,
overallTimeout: timeout,
);
return (status, '');
}
return await _readFullResponse(socket, timeout: timeout);
} finally {
try {
socket.destroy();
} catch (_) {}
}
}
void _writeRequestHeaders(
Socket socket,
Uri uri,
String method,
Map<String, String> headers,
) {
final path = '${uri.path}${uri.hasQuery ? "?${uri.query}" : ""}';
final buffer = StringBuffer()..write('$method $path HTTP/1.1\r\n');
for (final entry in headers.entries) {
buffer.write('${entry.key}: ${entry.value}\r\n');
}
buffer.write('\r\n');
socket.add(utf8.encode(buffer.toString()));
}
Stream<List<int>> _withProgress(
Stream<List<int>> src,
int total,
void Function(int sent, int total) onProgress, {
Duration throttle = const Duration(milliseconds: 16),
}) {
final stopwatch = Stopwatch()..start();
var sent = 0;
return src.map((chunk) {
sent += chunk.length;
if (stopwatch.elapsed >= throttle) {
onProgress(sent, total);
stopwatch.reset();
}
return chunk;
});
}
String _contentTypeForFilename(String filename) {
final ext = filename.contains('.')
? filename.split('.').last.toLowerCase()
: '';
switch (ext) {
case 'png':
return 'image/png';
case 'gif':
return 'image/gif';
case 'webp':
return 'image/webp';
case 'heic':
case 'heif':
return 'image/heic';
case 'bmp':
return 'image/bmp';
case 'jpg':
case 'jpeg':
default:
return 'image/jpeg';
}
}
Future<(int, String)?> _readFullResponse(
Socket socket, {
required Duration timeout,
}) {
final bytes = <int>[];
final completer = Completer<(int, String)?>();
Timer? timer;
StreamSubscription<List<int>>? sub;
void finishWith((int, String)? value) {
timer?.cancel();
sub?.cancel();
if (!completer.isCompleted) completer.complete(value);
}
(int, String)? tryParse({required bool atClose}) {
final headerEnd = _findHeaderEnd(bytes);
if (headerEnd == -1) return null;
final headerStr = utf8.decode(
bytes.sublist(0, headerEnd),
allowMalformed: true,
);
final lines = headerStr.split('\r\n');
final parts = lines.first.split(' ');
final status = parts.length >= 2 ? (int.tryParse(parts[1]) ?? 0) : 0;
final headerLines = lines.skip(1);
final chunked = headerLines.any(
(l) =>
l.toLowerCase().startsWith('transfer-encoding:') &&
l.toLowerCase().contains('chunked'),
);
int? contentLength;
for (final l in headerLines) {
if (l.toLowerCase().startsWith('content-length:')) {
contentLength = int.tryParse(l.split(':').last.trim());
}
}
final rawBody = utf8.decode(
bytes.sublist(headerEnd),
allowMalformed: true,
);
if (chunked) {
if (!atClose && !rawBody.contains('\r\n0\r\n')) return null;
return (status, _decodeChunked(rawBody));
}
if (contentLength != null &&
!atClose &&
bytes.length - headerEnd < contentLength) {
return null;
}
return (status, rawBody);
}
sub = socket.listen(
(chunk) {
bytes.addAll(chunk);
final parsed = tryParse(atClose: false);
if (parsed != null) finishWith(parsed);
},
onError: (e) {
logger.w('uploadImage: socket error after ${bytes.length} bytes: $e');
finishWith(tryParse(atClose: true));
},
onDone: () {
final parsed = tryParse(atClose: true);
if (parsed == null) {
logger.w(
'uploadImage: connection closed without HTTP response (${bytes.length} bytes)',
);
}
finishWith(parsed);
},
);
timer = Timer(timeout, () {
logger.w('uploadImage: response timeout after ${bytes.length} bytes');
finishWith(tryParse(atClose: true));
});
return completer.future;
}
String _decodeChunked(String body) {
final out = StringBuffer();
var i = 0;
while (i < body.length) {
final lineEnd = body.indexOf('\r\n', i);
if (lineEnd < 0) break;
final sizeStr = body.substring(i, lineEnd).split(';').first.trim();
if (sizeStr.isEmpty) {
i = lineEnd + 2;
continue;
}
final size = int.tryParse(sizeStr, radix: 16);
if (size == null) break;
if (size == 0) break;
final dataStart = lineEnd + 2;
if (dataStart + size > body.length) break;
out.write(body.substring(dataStart, dataStart + size));
i = dataStart + size;
if (i + 2 <= body.length && body.substring(i, i + 2) == '\r\n') {
i += 2;
var status = 0;
var body = Uint8List(0);
String? error;
await for (final event in stream) {
switch (event) {
case kb.UploadEvent_Progress(:final sent, :final total):
onProgress?.call(sent.toInt(), total.toInt());
case kb.UploadEvent_Done(status: final s, body: final b):
status = s;
body = b;
case kb.UploadEvent_Error(:final message):
error = message;
}
}
return out.toString();
return (status: status, body: body, error: error);
}
String _syntheticFilename() =>
(DateTime.now().microsecondsSinceEpoch & 0x7FFFFFFF).toString();
String? _parsePhotoToken(String body) {
try {
final json = jsonDecode(body);
@@ -701,79 +338,4 @@ class FileUploader {
}
return null;
}
Future<int> _readResponse(
Socket socket, {
required Duration autoForceAfter,
required Duration overallTimeout,
}) {
final responseBytes = <int>[];
final completer = Completer<int>();
Timer? force;
Timer? overall;
StreamSubscription<List<int>>? sub;
void finish(int code) {
if (completer.isCompleted) return;
force?.cancel();
overall?.cancel();
sub?.cancel();
completer.complete(code);
}
void fail(Object e) {
if (completer.isCompleted) return;
force?.cancel();
overall?.cancel();
sub?.cancel();
completer.completeError(e);
}
force = Timer(autoForceAfter, () => finish(0));
sub = socket.listen(
responseBytes.addAll,
onError: fail,
onDone: () {
final code = _parseHttpStatus(responseBytes);
if (code == null) {
fail(const SocketException('Не удалось прочитать заголовок ответа'));
} else {
finish(code);
}
},
);
overall = Timer(
overallTimeout,
() => fail(TimeoutException('Тайм-аут загрузки')),
);
return completer.future;
}
int? _parseHttpStatus(List<int> bytes) {
final headerEnd = _findHeaderEnd(bytes);
if (headerEnd == -1) return null;
final headerStr = utf8.decode(
bytes.sublist(0, headerEnd),
allowMalformed: true,
);
final statusLine = headerStr.split('\r\n').first;
final parts = statusLine.split(' ');
if (parts.length < 2) return null;
return int.tryParse(parts[1]);
}
int _findHeaderEnd(List<int> bytes) {
for (var i = 0; i < bytes.length - 3; i++) {
if (bytes[i] == 0x0D &&
bytes[i + 1] == 0x0A &&
bytes[i + 2] == 0x0D &&
bytes[i + 3] == 0x0A) {
return i + 4;
}
}
return -1;
}
}
+12 -69
View File
@@ -1,7 +1,4 @@
import 'dart:convert';
import 'dart:typed_data';
import '../protocol/lz4_block.dart';
import 'package:kolibri/kolibri.dart' as kb;
/// Параметры подключения к звонку (`vcp`), которые сервер присылает в пуше
/// входящего звонка (opcode 137) и в ответе на инициацию исходящего.
@@ -79,73 +76,19 @@ class ConversationParams {
return nowSec >= expiresAt! - 5;
}
static List<String> _splitTurn(Object? value) {
if (value is! String || value.isEmpty) return const [];
return value
.split(',')
.map((e) => e.trim())
.where((e) => e.isNotEmpty)
.toList();
}
static List<String> _stringList(Object? value) {
if (value is! List) return const [];
return value.whereType<String>().toList();
}
/// Распаковывает и парсит строку `vcp`. Возвращает `null`, если формат
/// не распознан.
/// Распаковывает и парсит строку `vcp` через Rust-ядро (kolibri). Возвращает
/// `null`, если формат не распознан. Требует инициализации `initKolibri()`.
static ConversationParams? decode(String vcp) {
final sep = vcp.indexOf(':');
if (sep <= 0) return null;
final rawLen = int.tryParse(vcp.substring(0, sep));
if (rawLen == null || rawLen <= 0) return null;
final Uint8List compressed;
try {
compressed = base64.decode(vcp.substring(sep + 1));
} catch (_) {
return null;
}
final Uint8List bytes;
try {
final decompressed = lz4BlockDecompress(compressed, rawLen);
bytes = decompressed.length > rawLen
? Uint8List.sublistView(decompressed, 0, rawLen)
: decompressed;
} catch (_) {
return null;
}
final Object? json;
try {
json = jsonDecode(utf8.decode(bytes));
} catch (_) {
return null;
}
if (json is! Map) return null;
final token = json['tkn'];
final wse = json['wse'];
if (token is! String || wse is! String) return null;
final kb.CallParams? p = kb.decodeVcp(vcp: vcp, conversationId: '');
if (p == null) return null;
return ConversationParams(
token: token,
wsEndpoint: wse,
wsIps: _stringList(json['wsip']),
wtEndpoint: json['wte'] as String?,
wtIps: _stringList(json['wtip']),
callsApiEndpoint: json['vcae'] as String?,
callsApiIps: _stringList(json['vcaip']),
clientType: json['srcp'] as String?,
expiresAt: json['et'] is int ? json['et'] as int : null,
stun: json['stne'] as String?,
turn: _splitTurn(json['trne']),
turnUser: json['trnu'] as String?,
turnPassword: json['trnp'] as String?,
isVideo: json['iv'] == true,
token: p.token,
wsEndpoint: p.wsEndpoint,
stun: p.stun,
turn: p.turn,
turnUser: p.turnUser,
turnPassword: p.turnPassword,
isVideo: p.isVideo,
);
}
}
+45 -87
View File
@@ -1,8 +1,8 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import '../utils/logger.dart';
import 'package:kolibri/kolibri.dart' as kb;
import 'conversation_params.dart';
/// Параметры подключения к сигналинг-сокету ws2.
@@ -84,17 +84,19 @@ class Ws2CommandException implements Exception {
/// Клиент сигналинга звонка поверх WebSocket `ws2`.
///
/// Конверт сообщений (подтверждено захватом `docs/ws2_capture.log`):
/// Тонкий адаптер над Rust-ядром (kolibri [kb.CallSignaling]): ядро держит
/// WebSocket, корреляцию `sequence`/`response`, keepalive `ping`→`pong` и
/// разбор кадров; здесь — прежний Dart-интерфейс для [call_session].
///
/// Конверт сообщений:
/// - запрос: `{"command": ..., ..., "sequence": N}`
/// - ответ: `{"sequence": N, "response": "<command>", "type": "response"}`
/// - пуш: `{..., "notification": "<name>", "type": "notification"}`
/// - keepalive: текстовый кадр `ping` → ответ `pong`.
class Ws2Signaling {
final Ws2Config config;
WebSocket? _socket;
int _sequence = 0;
final Map<int, Completer<Map<String, dynamic>>> _pending = {};
kb.CallSignaling? _call;
StreamSubscription<String>? _notifSub;
final _notifications = StreamController<Map<String, dynamic>>.broadcast();
final _closed = Completer<Object?>();
@@ -104,106 +106,60 @@ class Ws2Signaling {
/// Пуши сервера (`type == "notification"`). Фильтруй по полю `notification`.
Stream<Map<String, dynamic>> get notifications => _notifications.stream;
/// Завершается, когда сокет закрыт (значение — причина закрытия, если была).
/// Завершается, когда сокет закрыт.
Future<Object?> get done => _closed.future;
bool get isConnected => _socket != null;
bool get isConnected => _call?.isConnected() ?? false;
Future<void> connect() async {
final socket = await WebSocket.connect(
config.uri.toString(),
headers: {'User-Agent': 'okhttp/4.12.0'},
final call = await kb.connectCallSignaling(
url: config.uri.toString(),
userAgent: 'okhttp/4.12.0',
);
_socket = socket;
socket.listen(
_onFrame,
onError: _onDone,
_call = call;
_notifSub = call.notifications().listen(
(json) {
Object? decoded;
try {
decoded = jsonDecode(json);
} catch (_) {
return;
}
if (decoded is Map<String, dynamic>) _notifications.add(decoded);
},
onError: (_) => _onDone(null),
onDone: () => _onDone(null),
cancelOnError: false,
);
}
void _onFrame(dynamic frame) {
if (frame is String && frame == 'ping') {
_socket?.add('pong');
return;
}
final String text;
if (frame is String) {
text = frame;
} else if (frame is List<int>) {
text = utf8.decode(frame);
} else {
return;
}
Object? decoded;
try {
decoded = jsonDecode(text);
} catch (_) {
return;
}
if (decoded is! Map<String, dynamic>) return;
final label =
decoded['notification'] ?? decoded['response'] ?? decoded['type'];
final dump = jsonEncode(decoded);
logger.t('[ws2] ← $label');
logger.t(dump.length > 1500
? '${dump.substring(0, 1500)}… (${dump.length}b)'
: dump);
final type = decoded['type'];
if (type == 'response' || type == 'error') {
final seq = decoded['sequence'];
if (seq is int) {
final completer = _pending.remove(seq);
if (completer != null && !completer.isCompleted) {
completer.complete(decoded);
}
}
if (type == 'error') _notifications.add(decoded);
return;
}
if (type == 'notification' || decoded.containsKey('notification')) {
_notifications.add(decoded);
}
}
void _onDone(Object? error) {
for (final c in _pending.values) {
if (!c.isCompleted) c.completeError(error ?? const SocketException('ws2 closed'));
}
_pending.clear();
if (!_closed.isCompleted) _closed.complete(error);
if (!_notifications.isClosed) _notifications.close();
}
/// Отправляет команду и ждёт ответ сервера. Бросает [Ws2CommandException],
/// если в ответе есть поле `error`.
/// если сервер вернул ошибку.
Future<Map<String, dynamic>> sendCommand(
String command, {
Map<String, dynamic> extra = const {},
Duration timeout = const Duration(seconds: 15),
}) {
final socket = _socket;
if (socket == null) {
}) async {
final call = _call;
if (call == null) {
return Future.error(StateError('ws2 не подключён'));
}
final seq = ++_sequence;
final completer = Completer<Map<String, dynamic>>();
_pending[seq] = completer;
socket.add(jsonEncode({'command': command, ...extra, 'sequence': seq}));
return completer.future.timeout(timeout).then((response) {
final error = response['error'];
if (error != null) throw Ws2CommandException(command, error);
return response;
});
try {
final response = await call
.sendCommand(command: command, extraJson: jsonEncode(extra))
.timeout(timeout);
final decoded = jsonDecode(response);
return decoded is Map<String, dynamic>
? decoded
: <String, dynamic>{};
} catch (e) {
throw Ws2CommandException(command, e);
}
}
/// Передаёт SDP (offer/answer) другому участнику.
@@ -329,7 +285,9 @@ class Ws2Signaling {
extra: {'mediaSource': mediaSource, 'layers': layers});
Future<void> close() async {
await _socket?.close();
_socket = null;
await _notifSub?.cancel();
_notifSub = null;
_call?.close();
_call = null;
}
}
-71
View File
@@ -1,71 +0,0 @@
import 'dart:typed_data';
/// LZ4 block декомпрессия (без frame-заголовка).
///
/// Сервер шлёт block-формат как в транспорте (payload пакетов), так и в
/// `vcp`-параметрах звонка. dart_lz4 поддерживает только frame-формат, поэтому
/// block распаковывается вручную.
Uint8List lz4BlockDecompress(Uint8List src, int maxSize) {
var out = Uint8List(1024);
int outLen = 0;
int pos = 0;
void ensure(int extra) {
if (outLen + extra > maxSize) throw StateError('LZ4: превышен лимит');
if (outLen + extra <= out.length) return;
var newCap = out.length * 2;
while (newCap < outLen + extra) {
newCap *= 2;
}
if (newCap > maxSize) newCap = maxSize;
final grown = Uint8List(newCap);
grown.setRange(0, outLen, out);
out = grown;
}
while (pos < src.length) {
final token = src[pos++];
var litLen = token >> 4;
if (litLen == 15) {
while (pos < src.length) {
final b = src[pos++];
litLen += b;
if (b != 255) break;
}
}
if (litLen > 0) {
ensure(litLen);
out.setRange(outLen, outLen + litLen, src, pos);
outLen += litLen;
pos += litLen;
}
if (pos >= src.length) break;
if (pos + 1 >= src.length) throw StateError('LZ4: unexpected end of input');
final offset = src[pos] | (src[pos + 1] << 8);
pos += 2;
if (offset == 0) throw StateError('LZ4: offset = 0');
var matchLen = (token & 0x0F) + 4;
if ((token & 0x0F) == 0x0F) {
while (pos < src.length) {
final b = src[pos++];
matchLen += b;
if (b != 255) break;
}
}
ensure(matchLen);
final start = outLen - offset;
if (start < 0) throw StateError('LZ4: offset за пределами вывода');
for (var i = 0; i < matchLen; i++) {
out[outLen + i] = out[start + i];
}
outLen += matchLen;
}
return Uint8List.sublistView(out, 0, outLen);
}
+4 -152
View File
@@ -1,16 +1,3 @@
import 'dart:typed_data';
import 'dart:isolate';
import 'package:dart_lz4/dart_lz4.dart';
import 'package:libcompress/libcompress.dart';
import 'package:msgpack_dart/msgpack_dart.dart' as msgpack;
import 'lz4_block.dart';
/// ver(1) + cmd(1) + seq(2) + opcode(2) + packedLen(4) = 10
const int headerSize = 10;
/// Потолок распаковки payload (анти-бомба); буфер растёт динамически до него.
const int _maxDecompressedSize = 32 * 1024 * 1024; // 32 MB
/// Типы команд в протоколе
abstract class CmdType {
static const int request =
@@ -22,17 +9,11 @@ abstract class CmdType {
static const int error = 3; // ответ: ошибка
}
/// Распакованный бинарный пакет
/// Распакованный пакет.
///
/// Формат заголовка (10 байт):
/// ```
/// [0] ver — версия протокола (uint8) (по умолчанию 10)
/// [1] cmd — тип команды (uint8) (при отправке от клиента равно 0)
/// [2..3] seq — порядковый номер (uint16 BE)
/// [4..5] opcode — код операции (uint16 BE)
/// [6..9] packedLen — флаг сжатия [6] + длина payload [7..9] (uint32 BE)
/// [10..] payload — данные в MsgPack, опционально сжатые LZ4
/// ```
/// Провод (фрейминг, MsgPack, сжатие) живёт в Rust-ядре kolibri; здесь пакет —
/// это уже декодированный [payload] (Map/List/скаляр, бинарь — Uint8List) плюс
/// метаданные заголовка.
class Packet {
int api;
int cmd;
@@ -109,132 +90,3 @@ bool isSessionStateError(Object error) {
text.contains('авторизационная сессия') ||
text.contains('сессия не онлайн');
}
/// Payload меньше этого размера отправляется без сжатия (как в оригинале).
const int _compressionThreshold = 32;
/// Упаковка пакета для отправки на сервер.
///
/// Payload сериализуется в MsgPack и при размере >= [_compressionThreshold]
/// сжимается LZ4-block. Старший байт поля packedLen — флаг сжатия:
/// `0` — без сжатия, иначе `(rawLen ~/ compLen) + 1` (множитель размера, по
/// которому получатель выделяет буфер под распаковку).
Uint8List packPacket(int opcode, Map<dynamic, dynamic> payload, {int seq = 0}) {
final Uint8List raw = msgpack.serialize(payload);
final List<int> body;
final int flag;
if (raw.length < _compressionThreshold) {
body = raw;
flag = 0;
} else {
body = lz4Compress(raw);
flag = (raw.length ~/ body.length) + 1;
}
final out = Uint8List(headerSize + body.length);
final header = ByteData.view(out.buffer, out.offsetInBytes, headerSize);
header.setUint8(0, 10);
header.setUint8(1, CmdType.request);
header.setUint16(2, seq, Endian.big);
header.setUint16(4, opcode, Endian.big);
header.setUint32(
6,
((flag & 0xFF) << 24) | (body.length & 0xFFFFFF),
Endian.big,
);
out.setRange(headerSize, out.length, body);
return out;
}
const int _isolateDecodeThreshold = 4096;
Future<Packet> unpackPacket(Uint8List packet) async {
final header = ByteData.sublistView(packet);
final apiVer = header.getUint8(0) & 0xFF;
final cmd = header.getUint8(1) & 0xFF;
final seq = header.getUint16(2) & 0xFFFF;
final opcode = header.getUint16(4) & 0xFFFF;
final packedLen = header.getUint32(6);
final compFlag = packedLen >> 24;
final payloadLength = packedLen & 0xFFFFFF;
if (payloadLength == 0) {
return Packet(api: apiVer, cmd: cmd, seq: seq, opcode: opcode);
}
final end = headerSize + payloadLength;
if (end > packet.length) {
throw Exception('Packet payload length $payloadLength exceeds buffer');
}
final slice = Uint8List.sublistView(packet, headerSize, end);
dynamic payload;
if (compFlag == 0 && slice.length < _isolateDecodeThreshold) {
payload = _deserializePayload(slice, compFlag);
} else {
final owned = Uint8List.fromList(slice);
payload = await Isolate.run(() => _deserializePayload(owned, compFlag));
}
return Packet(
api: apiVer,
cmd: cmd,
seq: seq,
opcode: opcode,
payload: payload,
);
}
dynamic _deserializePayload(Uint8List payloadBytes, int compFlag) {
var bytes = payloadBytes;
if (compFlag != 0) {
bytes = _decompressPayload(bytes);
}
if (bytes.isEmpty) return null;
try {
return msgpack.deserialize(bytes);
} catch (e) {
throw Exception('MsgPack deserialization error: $e');
}
}
/// Определяет формат сжатия по magic-number и распаковывает payload.
/// Сервер может присылать LZ4 block ИЛИ Zstandard в зависимости от ответа.
Uint8List _decompressPayload(Uint8List src) {
// Zstandard: magic 28 B5 2F FD (little-endian)
if (src.length >= 4 &&
src[0] == 0x28 &&
src[1] == 0xB5 &&
src[2] == 0x2F &&
src[3] == 0xFD) {
try {
return ZstdCodec(
maxDecompressedSize: _maxDecompressedSize,
).decompress(src);
} catch (e) {
throw Exception('Zstd decompression error: $e');
}
}
// LZ4 frame: magic 04 22 4D 18
if (src.length >= 4 &&
src[0] == 0x04 &&
src[1] == 0x22 &&
src[2] == 0x4D &&
src[3] == 0x18) {
try {
return lz4Decompress(src, decompressedSize: _maxDecompressedSize);
} catch (e) {
throw Exception('LZ4 frame decompression error: $e');
}
}
// По умолчанию — LZ4 block (без magic)
try {
return lz4BlockDecompress(src, _maxDecompressedSize);
} catch (e) {
throw Exception('LZ4 block decompression error: $e');
}
}
+5
View File
@@ -5,6 +5,7 @@ import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:kolibri/kolibri.dart' show initKolibri;
import 'package:shared_preferences/shared_preferences.dart';
import '../../backend/api.dart';
@@ -53,6 +54,9 @@ Future<void> _handleCallDecline(String payloadJson) async {
}
if (vcp.isEmpty || conversationId.isEmpty) return;
// Фоновый изолят: инициализируем ядро перед vcp-декодом/сигналингом.
await initKolibri();
final params = ConversationParams.decode(vcp);
if (params == null) return;
@@ -83,6 +87,7 @@ Future<void> _handleReply(String payloadJson, String text) async {
if (account == 0 || chatId == 0) return;
WidgetsFlutterBinding.ensureInitialized();
await initKolibri();
if (AppInstance.isNamed) {
try {
SharedPreferences.setPrefix('flutter.${AppInstance.id}.');
-189
View File
@@ -1,189 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import '../config/proxy_config.dart';
import '../utils/logger.dart';
import 'proxy_connector.dart';
import 'tls_config.dart';
import 'traffic_monitor.dart';
import 'vpn_bypass.dart';
enum SocketState { disconnected, connecting, connected }
/// Обёртка над TCP + TLS сокетом.
/// Отдаёт сырые байты через [dataStream], сборкой пакетов занимается [PacketReceiver].
class Connection {
static const Duration _defaultConnectTimeout = Duration(seconds: 15);
static const Duration _proxyLoadTimeout = Duration(seconds: 8);
static const Duration _vpnCallTimeout = Duration(seconds: 5);
SecureSocket? _socket;
StreamSubscription<Uint8List>? _subscription;
SocketState _state = SocketState.disconnected;
final _dataController = StreamController<Uint8List>.broadcast();
final _stateController = StreamController<SocketState>.broadcast();
Stream<Uint8List> get dataStream => _dataController.stream;
Stream<SocketState> get stateStream => _stateController.stream;
SocketState get state => _state;
bool get isConnected => _state == SocketState.connected;
void _setState(SocketState newState) {
if (_state == newState) return;
_state = newState;
if (!_stateController.isClosed) _stateController.add(newState);
}
Future<void> connect(
String host,
int port, {
bool bypassVpn = false,
Duration? timeout,
}) async {
if (_state != SocketState.disconnected) {
logger.w('Connection.connect пропущен: state=$_state (уже $_state)');
return;
}
_setState(SocketState.connecting);
try {
logger.i('Connection: загрузка прокси-конфига');
ProxySettings proxySettings;
try {
proxySettings = await ProxyConfig.load().timeout(_proxyLoadTimeout);
} catch (e) {
logger.w('Connection: ProxyConfig.load завис/упал ($e) — без прокси');
proxySettings = const ProxySettings();
}
logger.i(
'Connection: VPN ${bypassVpn ? 'bind (обход)' : 'restoreDefault'}',
);
try {
if (bypassVpn) {
await VpnBypassService.instance.bind().timeout(_vpnCallTimeout);
} else {
await VpnBypassService.instance
.restoreDefault()
.timeout(_vpnCallTimeout);
}
} catch (e) {
logger.w('Connection: VPN-вызов завис/упал ($e) — продолжаю');
}
logger.i(
'Connection: открываю сокет $host:$port '
'(прокси: ${proxySettings.isEnabled ? proxySettings.type.name : 'нет'})',
);
final socket = await _openSecureSocket(
host,
port,
proxySettings,
timeout: timeout,
);
_socket = socket;
_setState(SocketState.connected);
logger.i('Подключено к $host:$port');
final route = proxySettings.isEnabled
? 'через прокси ${proxySettings.type.name}'
: bypassVpn
? 'напрямую (обход VPN)'
: 'прямое соединение';
TrafficMonitor.instance.recordEvent(
'Подключено',
detail: '$host:$port · TLS · $route',
endpoint: '$host:$port',
);
_subscription = _socket!.listen(
(data) {
if (!_dataController.isClosed) _dataController.add(data);
},
onError: (Object error) {
logger.e('Ошибка сокета: $error');
disconnect();
},
onDone: () {
logger.w('Сокет закрыт сервером');
disconnect();
},
);
} catch (e) {
logger.e('Не удалось подключиться: $e');
_setState(SocketState.disconnected);
rethrow;
}
}
Future<SecureSocket> _openSecureSocket(
String host,
int port,
ProxySettings proxySettings, {
Duration? timeout,
}) async {
final connectTimeout = timeout ?? _defaultConnectTimeout;
Socket socket;
if (proxySettings.isEnabled) {
final connector = ProxyConnector(proxySettings);
socket = await connector.connect(host, port).timeout(connectTimeout);
logger.i('Подключено через прокси ${proxySettings.type.name}');
} else {
logger.i('Connection: TCP connect $host:$port (лимит ${connectTimeout.inSeconds}с)');
socket = await Socket.connect(host, port, timeout: connectTimeout);
logger.i('Connection: TCP установлен, начинаю TLS');
}
final allowInsecure = await TlsConfig.isInsecureAllowed();
if (allowInsecure) {
logger.w(
'TLS: проверка сертификата отключена (дебаг) — соединение уязвимо к MitM',
);
}
final secured = allowInsecure
? SecureSocket.secure(socket, host: host, onBadCertificate: (_) => true)
: SecureSocket.secure(socket, host: host);
try {
final result = await secured.timeout(connectTimeout);
logger.i('Connection: TLS-handshake завершён');
return result;
} on TimeoutException {
logger.w('Connection: TLS-handshake таймаут ${connectTimeout.inSeconds}с');
socket.destroy();
rethrow;
}
}
void write(Uint8List data) {
if (_socket == null || !isConnected) {
throw StateError('Нельзя писать: сокет не подключён');
}
_socket!.add(data);
}
Future<void> disconnect() async {
_subscription?.cancel();
_subscription = null;
final socket = _socket;
_socket = null;
if (socket != null) {
TrafficMonitor.instance.recordEvent('Соединение закрыто');
try {
await socket.close();
} catch (e) {
logger.w('Ошибка при закрытии сокета: $e');
}
}
_setState(SocketState.disconnected);
}
Future<void> dispose() async {
await disconnect();
await _dataController.close();
await _stateController.close();
}
}
-360
View File
@@ -1,360 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import '../config/proxy_config.dart';
import '../utils/logger.dart';
class ProxyConnector {
final ProxySettings settings;
ProxyConnector(this.settings);
Future<Socket> connect(String targetHost, int targetPort) async {
switch (settings.type) {
case ProxyType.socks5:
return _connectSocks5(targetHost, targetPort);
case ProxyType.httpConnect:
return _connectHttpConnect(targetHost, targetPort);
case ProxyType.none:
return Socket.connect(targetHost, targetPort);
}
}
// ── SOCKS5 (RFC 1928) ──────────────────────────────────────────────────
Future<Socket> _connectSocks5(String targetHost, int targetPort) async {
final proxySocket = await RawSocket.connect(settings.host, settings.port);
logger.i('SOCKS5: подключено к прокси ${settings.host}:${settings.port}');
final io = _RawSocketIO(proxySocket);
try {
// 1. Greeting
final useAuth = settings.hasCredentials;
if (useAuth) {
await io.write([0x05, 0x02, 0x00, 0x02]);
} else {
await io.write([0x05, 0x01, 0x00]);
}
var response = await io.readExact(2);
if (response[0] != 0x05) {
throw SocketException(
'SOCKS5: неверная версия протокола: ${response[0]}',
);
}
final method = response[1];
if (method == 0xFF) {
throw SocketException(
'SOCKS5: сервер отклонил все методы аутентификации',
);
}
// 2. Аутентификация (RFC 1929)
if (method == 0x02) {
if (!useAuth) {
throw SocketException('SOCKS5: прокси требует аутентификацию');
}
final usernameBytes = utf8.encode(settings.username ?? '');
final passwordBytes = utf8.encode(settings.password ?? '');
final authPacket = BytesBuilder()
..addByte(0x01)
..addByte(usernameBytes.length)
..add(usernameBytes)
..addByte(passwordBytes.length)
..add(passwordBytes);
await io.write(authPacket.toBytes());
final authResponse = await io.readExact(2);
if (authResponse[1] != 0x00) {
throw SocketException('SOCKS5: аутентификация не пройдена');
}
logger.i('SOCKS5: аутентификация пройдена');
}
// 3. Connect request
final hostBytes = utf8.encode(targetHost);
final connectPacket = BytesBuilder()
..addByte(0x05) // VER
..addByte(0x01) // CMD: CONNECT
..addByte(0x00) // RSV
..addByte(0x03) // ATYP: domain
..addByte(hostBytes.length)
..add(hostBytes)
..addByte((targetPort >> 8) & 0xFF)
..addByte(targetPort & 0xFF);
await io.write(connectPacket.toBytes());
// 4. Reply
final reply = await io.readExact(4);
if (reply[0] != 0x05) {
throw SocketException('SOCKS5: неверная версия в ответе');
}
if (reply[1] != 0x00) {
throw SocketException('SOCKS5: ошибка подключения, код: ${reply[1]}');
}
// Пропускаем bind address
switch (reply[3]) {
case 0x01:
await io.readExact(4 + 2);
break;
case 0x03:
final lenBuf = await io.readExact(1);
await io.readExact(lenBuf[0] + 2);
break;
case 0x04:
await io.readExact(16 + 2);
break;
}
logger.i('SOCKS5: туннель к $targetHost:$targetPort установлен');
// Создаём локальную пару и проксируем данные
return _bridgeToFreshSocket(proxySocket, io);
} catch (e) {
io.dispose();
proxySocket.close();
rethrow;
}
}
// ── HTTP CONNECT ────────────────────────────────────────────────────────
Future<Socket> _connectHttpConnect(String targetHost, int targetPort) async {
final proxySocket = await RawSocket.connect(settings.host, settings.port);
logger.i(
'HTTP CONNECT: подключено к прокси ${settings.host}:${settings.port}',
);
final io = _RawSocketIO(proxySocket);
try {
final request = StringBuffer()
..write('CONNECT $targetHost:$targetPort HTTP/1.1\r\n')
..write('Host: $targetHost:$targetPort\r\n');
if (settings.hasCredentials) {
final credentials = base64Encode(
utf8.encode('${settings.username}:${settings.password}'),
);
request.write('Proxy-Authorization: Basic $credentials\r\n');
}
request.write('\r\n');
await io.write(utf8.encode(request.toString()));
// Читаем HTTP-ответ до \r\n\r\n
final headerBytes = <int>[];
while (true) {
final byte = await io.readExact(1);
headerBytes.add(byte[0]);
if (headerBytes.length >= 4 &&
headerBytes[headerBytes.length - 4] == 0x0D &&
headerBytes[headerBytes.length - 3] == 0x0A &&
headerBytes[headerBytes.length - 2] == 0x0D &&
headerBytes[headerBytes.length - 1] == 0x0A) {
break;
}
if (headerBytes.length > 8192) {
throw SocketException(
'HTTP CONNECT: заголовок ответа слишком большой',
);
}
}
final responseStr = utf8.decode(headerBytes, allowMalformed: true);
final statusLine = responseStr.split('\r\n').first;
final parts = statusLine.split(' ');
if (parts.length < 2) {
throw SocketException('HTTP CONNECT: некорректный ответ: $statusLine');
}
final statusCode = int.tryParse(parts[1]) ?? 0;
if (statusCode != 200) {
throw SocketException('HTTP CONNECT: прокси вернул статус $statusCode');
}
logger.i('HTTP CONNECT: туннель к $targetHost:$targetPort установлен');
return _bridgeToFreshSocket(proxySocket, io);
} catch (e) {
io.dispose();
proxySocket.close();
rethrow;
}
}
Future<Socket> _bridgeToFreshSocket(
RawSocket proxySocket,
_RawSocketIO io,
) async {
ServerSocket? server;
try {
server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
} catch (e) {
io.dispose();
proxySocket.close();
rethrow;
}
final clientFuture = Socket.connect(
InternetAddress.loopbackIPv4,
server.port,
);
final serverSide = await server.first;
final clientSide = await clientFuture;
await server.close();
io.onData = (data) {
serverSide.add(data);
};
io.onClosed = () {
serverSide.close();
};
serverSide.listen(
(data) {
unawaited(
io.write(data).catchError((Object _) {
try {
serverSide.destroy();
} catch (_) {}
}),
);
},
onError: (Object _) {
proxySocket.shutdown(SocketDirection.send);
},
onDone: () {
proxySocket.shutdown(SocketDirection.send);
},
);
// Сливаем данные, буферизованные во время handshake
io.flushBuffered();
logger.i('Прокси-мост через loopback создан');
return clientSide;
}
}
/// Обёртка над единственной подпиской [RawSocket], с буфером для чтения.
///
/// После handshake переключается в режим моста:
/// данные из proxy-сокета пересылаются через [onData] в loopback-пару.
class _RawSocketIO {
final RawSocket _socket;
late final StreamSubscription<RawSocketEvent> _sub;
final _readBuffer = <int>[];
Completer<void>? _readWaiter;
Completer<void>? _writeWaiter;
bool _closed = false;
Object? _error;
/// Коллбэк для данных в режиме моста.
void Function(Uint8List data)? onData;
/// Коллбэк закрытия в режиме моста.
void Function()? onClosed;
_RawSocketIO(this._socket) {
_sub = _socket.listen(
_onEvent,
onError: (Object err) {
_error = err;
_closed = true;
_readWaiter?.completeError(err);
_readWaiter = null;
_writeWaiter?.completeError(err);
_writeWaiter = null;
},
);
}
void _onEvent(RawSocketEvent event) {
switch (event) {
case RawSocketEvent.read:
final data = _socket.read();
if (data != null) {
if (onData != null) {
// Режим моста — пересылаем напрямую
onData!(data);
} else {
// Режим handshake — буферизуем
_readBuffer.addAll(data);
_readWaiter?.complete();
_readWaiter = null;
}
}
break;
case RawSocketEvent.write:
_writeWaiter?.complete();
_writeWaiter = null;
break;
case RawSocketEvent.readClosed:
case RawSocketEvent.closed:
_closed = true;
onClosed?.call();
_readWaiter?.completeError(SocketException('Прокси закрыл соединение'));
_readWaiter = null;
_writeWaiter?.completeError(
SocketException('Прокси закрыл соединение'),
);
_writeWaiter = null;
break;
}
}
/// Читает ровно [count] байт.
Future<Uint8List> readExact(int count) async {
while (_readBuffer.length < count) {
if (_error != null) throw _error!;
if (_closed) {
throw SocketException(
'Соединение закрыто '
'(ожидали $count байт, получили ${_readBuffer.length})',
);
}
_readWaiter = Completer<void>();
await _readWaiter!.future.timeout(
const Duration(seconds: 15),
onTimeout: () => throw SocketException('Тайм-аут при чтении от прокси'),
);
}
final result = Uint8List.fromList(_readBuffer.sublist(0, count));
_readBuffer.removeRange(0, count);
return result;
}
/// Записывает все байты.
Future<void> write(List<int> data) async {
var offset = 0;
while (offset < data.length) {
if (_error != null) throw _error!;
if (_closed) throw SocketException('Соединение закрыто при записи');
final written = _socket.write(data, offset);
if (written > 0) {
offset += written;
} else {
_writeWaiter = Completer<void>();
await _writeWaiter!.future.timeout(
const Duration(seconds: 15),
onTimeout: () =>
throw SocketException('Тайм-аут при записи в прокси'),
);
}
}
}
/// Пересылает данные, оставшиеся в буфере после handshake, в мост.
void flushBuffered() {
if (_readBuffer.isNotEmpty && onData != null) {
onData!(Uint8List.fromList(_readBuffer));
_readBuffer.clear();
}
}
void dispose() {
_sub.cancel();
}
}
-74
View File
@@ -1,74 +0,0 @@
import 'dart:typed_data';
import '../protocol/packet.dart';
class ReceiverOverflowException implements Exception {
final int size;
const ReceiverOverflowException(this.size);
@override
String toString() => 'PacketReceiver: переполнение буфера ($size B)';
}
class PacketReceiver {
Uint8List _buffer = Uint8List(0);
int _start = 0;
int _end = 0;
static const int _maxBufferSize = 16 * 1024 * 1024;
List<Uint8List> feed(Uint8List data) {
_append(data);
if (_end - _start > _maxBufferSize) {
final overflow = _end - _start;
reset();
throw ReceiverOverflowException(overflow);
}
final packets = <Uint8List>[];
while (_end - _start >= headerSize) {
final bd = ByteData.view(
_buffer.buffer,
_buffer.offsetInBytes + _start,
headerSize,
);
final packedLen = bd.getUint32(6, Endian.big);
final payloadLength = packedLen & 0xFFFFFF;
final totalLength = headerSize + payloadLength;
if (_end - _start < totalLength) break;
packets.add(Uint8List.sublistView(_buffer, _start, _start + totalLength));
_start += totalLength;
}
if (_start == _end) {
_start = 0;
_end = 0;
}
return packets;
}
void _append(Uint8List data) {
final pending = _end - _start;
if (pending == 0) {
_buffer = Uint8List.fromList(data);
_start = 0;
_end = data.length;
return;
}
final total = pending + data.length;
final newBuffer = Uint8List(total);
newBuffer.setRange(0, pending, _buffer, _start);
newBuffer.setRange(pending, total, data);
_buffer = newBuffer;
_start = 0;
_end = total;
}
void reset() {
_buffer = Uint8List(0);
_start = 0;
_end = 0;
}
}
-27
View File
@@ -1,27 +0,0 @@
import '../protocol/packet.dart';
import '../utils/log_redact.dart';
import '../utils/logger.dart';
import 'connection.dart';
import 'traffic_monitor.dart';
class PacketSender {
int _seq = 0;
int get currentSeq => _seq;
int _nextSeq() {
_seq = (_seq + 1) % 65536;
return _seq;
}
int send(Connection connection, int opcode, Map<dynamic, dynamic> payload) {
final seq = _nextSeq();
final data = packPacket(opcode, payload, seq: seq);
connection.write(data);
TrafficMonitor.instance.recordOutgoing(opcode, payload, seq, data.length);
logger.i(
'=> {ver: 10, cmd: 0, seq: $seq, opcode: $opcode, payload: ${payloadForLog(payload)}}',
);
return seq;
}
}
@@ -6,6 +6,7 @@ import 'package:material_symbols_icons/symbols.dart';
import '../../../backend/modules/webapp.dart';
import '../../../core/storage/spoofing_service.dart';
import '../../../main.dart' show api;
import '../../widgets/connection_status.dart';
import '../../widgets/error_view.dart';
import '../../widgets/webview_permission_prompt.dart';
@@ -63,7 +64,11 @@ class _WebAppScreenState extends State<WebAppScreen> {
_launch = null;
});
try {
_userAgent = await SpoofingService.getWebViewUserAgent() ?? '';
// Тот же UA, что уходит в sessionInit (из handshake-устройства ядра),
// чтобы веб-аппы видели нативный клиент; фолбэк — браузерный UA спуфа.
_userAgent = api.session?.userAgent() ??
await SpoofingService.getWebViewUserAgent() ??
'';
final launch = await widget.loader();
if (!mounted) return;
setState(() => _launch = launch);
+5 -3
View File
@@ -4,6 +4,7 @@ import 'dart:ui' as ui;
import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter/cupertino.dart' show CupertinoPageTransitionsBuilder;
import 'package:kolibri/kolibri.dart' show initKolibri;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:video_player_media_kit/video_player_media_kit.dart';
@@ -159,6 +160,7 @@ void _installLogCapture() {
void main(List<String> args) async {
WidgetsFlutterBinding.ensureInitialized();
await initKolibri();
DebugTest.parse(args);
_installLogCapture();
VideoPlayerMediaKit.ensureInitialized(
@@ -435,9 +437,9 @@ class KometAppState extends State<KometApp>
_vpnBypassSub = VpnBypassService.instance.events.listen((r) {
final msg = r.bound
? 'Соединение через VPN не работает — '
'используется ${r.boundInterface ?? r.transport ?? 'прямое подключение'}'
: 'Соединение через VPN не работает, обойти не удалось'
? 'Обход VPN включён — прямое подключение через '
'${r.boundInterface ?? r.transport ?? 'сеть без VPN'}'
: 'Обход VPN не удался, подключение через туннель'
'${r.reason != null ? ' (${r.reason})' : ''}';
final now = DateTime.now();
+12 -1
View File
@@ -84,6 +84,8 @@ PODS:
- GoogleUtilities/UserDefaults (8.1.1):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- kolibri (0.0.1):
- FlutterMacOS
- media_kit_libs_macos_video (1.0.4):
- FlutterMacOS
- media_kit_video (0.0.1):
@@ -108,6 +110,7 @@ PODS:
- PromisesObjC (2.4.1)
- record_macos (1.2.1):
- FlutterMacOS
- rlottie (0.2.0)
- share_plus (0.0.1):
- FlutterMacOS
- shared_preferences_foundation (0.0.1):
@@ -139,6 +142,7 @@ DEPENDENCIES:
- flutter_webrtc (from `Flutter/ephemeral/.symlinks/plugins/flutter_webrtc/macos`)
- FlutterMacOS (from `Flutter/ephemeral`)
- geolocator_apple (from `Flutter/ephemeral/.symlinks/plugins/geolocator_apple/darwin`)
- kolibri (from `Flutter/ephemeral/.symlinks/plugins/kolibri/macos`)
- media_kit_libs_macos_video (from `Flutter/ephemeral/.symlinks/plugins/media_kit_libs_macos_video/macos`)
- media_kit_video (from `Flutter/ephemeral/.symlinks/plugins/media_kit_video/macos`)
- mobile_scanner (from `Flutter/ephemeral/.symlinks/plugins/mobile_scanner/darwin`)
@@ -146,6 +150,7 @@ DEPENDENCIES:
- package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`)
- photo_manager (from `Flutter/ephemeral/.symlinks/plugins/photo_manager/darwin`)
- record_macos (from `Flutter/ephemeral/.symlinks/plugins/record_macos/macos`)
- rlottie (from `../third_party`)
- share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`)
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`)
@@ -194,6 +199,8 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral
geolocator_apple:
:path: Flutter/ephemeral/.symlinks/plugins/geolocator_apple/darwin
kolibri:
:path: Flutter/ephemeral/.symlinks/plugins/kolibri/macos
media_kit_libs_macos_video:
:path: Flutter/ephemeral/.symlinks/plugins/media_kit_libs_macos_video/macos
media_kit_video:
@@ -208,6 +215,8 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/photo_manager/darwin
record_macos:
:path: Flutter/ephemeral/.symlinks/plugins/record_macos/macos
rlottie:
:path: "../third_party"
share_plus:
:path: Flutter/ephemeral/.symlinks/plugins/share_plus/macos
shared_preferences_foundation:
@@ -242,6 +251,7 @@ SPEC CHECKSUMS:
geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
GoogleUtilities: 4f2618a4a1e762a1ee134a1e2323bba9843e06da
kolibri: 93062ece67f68ec0b909876b527aa15e198b4a73
media_kit_libs_macos_video: 85a23e549b5f480e72cae3e5634b5514bc692f65
media_kit_video: fa6564e3799a0a28bff39442334817088b7ca758
mobile_scanner: 9157936403f5a0644ca3779a38ff8404c5434a93
@@ -252,6 +262,7 @@ SPEC CHECKSUMS:
photo_manager: 25fd77df14f4f0ba5ef99e2c61814dde77e2bceb
PromisesObjC: 752c3227f599e3467650e47ea36f433eeb10c273
record_macos: 5d55909f9650314be6424ffd6b123ac75a08c3c1
rlottie: c2780aabf858ca3c81785f09256827ca83b0765d
share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
@@ -260,6 +271,6 @@ SPEC CHECKSUMS:
wakelock_plus: 917609be14d812ddd9e9528876538b2263aaa03b
WebRTC-SDK: e6006119cd730d6315d875e4a421b6cc8bb88833
PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009
PODFILE CHECKSUM: f9f028435b3eb11958579119e016406574b87759
COCOAPODS: 1.16.2
+31 -24
View File
@@ -81,6 +81,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.2"
build_cli_annotations:
dependency: transitive
description:
name: build_cli_annotations
sha256: e563c2e01de8974566a1998410d3f6f03521788160a02503b0b1f1a46c7b3d95
url: "https://pub.dev"
source: hosted
version: "2.1.1"
button_group_m3e:
dependency: transitive
description:
@@ -193,14 +201,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.2"
dart_lz4:
dependency: "direct main"
description:
name: dart_lz4
sha256: e2e9c30fdf83a7a1e63bc6c4d90786cf795c38eefee40b1f4c35ea6480c91db9
url: "https://pub.dev"
source: hosted
version: "1.2.0"
dart_webrtc:
dependency: transitive
description:
@@ -499,6 +499,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.35"
flutter_rust_bridge:
dependency: transitive
description:
name: flutter_rust_bridge
sha256: e87d6b9ee934dcd24a128ccb2bd91905d2d5fe5c06245d6a8f5477d4907a437a
url: "https://pub.dev"
source: hosted
version: "2.12.0"
flutter_secure_storage:
dependency: "direct main"
description:
@@ -581,6 +589,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.5.2"
freezed_annotation:
dependency: transitive
description:
name: freezed_annotation
sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2
url: "https://pub.dev"
source: hosted
version: "2.4.4"
geolocator:
dependency: "direct main"
description:
@@ -733,6 +749,13 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.12.0"
kolibri:
dependency: "direct main"
description:
path: "third_party/kolibri/kolibri-dart"
relative: true
source: path
version: "0.1.0"
leak_tracker:
dependency: transitive
description:
@@ -757,14 +780,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.2"
libcompress:
dependency: "direct main"
description:
name: libcompress
sha256: "1f55be8dc9e622efa1584ad899e05880d71469b7107f35be1858e91d0fadf1d4"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
lints:
dependency: transitive
description:
@@ -917,14 +932,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "7.2.0"
msgpack_dart:
dependency: "direct main"
description:
name: msgpack_dart
sha256: c2d235ed01f364719b5296aecf43ac330f0d7bc865fa134d0d7910a40454dffb
url: "https://pub.dev"
source: hosted
version: "1.0.1"
native_toolchain_c:
dependency: transitive
description:
+6 -3
View File
@@ -34,11 +34,14 @@ dependencies:
sdk: flutter
intl: any
# Rust networking core (kolibri) — FFI plugin, replaces the Dart transport.
# DEV: local path for fast iteration; re-pin to third_party/kolibri submodule
# (path: third_party/kolibri/kolibri-dart) before merge.
kolibri:
path: third_party/kolibri/kolibri-dart
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
dart_lz4: ^1.0.0
libcompress: ^1.0.0
msgpack_dart: ^1.0.1
crypto: ^3.0.7
ffi: ^2.1.0
logger: ^2.6.2
Vendored Submodule
+1
Submodule third_party/kolibri added at 8f6836cac2
+15 -1
View File
@@ -24,10 +24,24 @@ Pod::Spec.new do |s|
'rlottie/src/vector/stb/*.{cpp,h}',
'rlottie/src/binding/c/*.cpp',
]
s.exclude_files = ['rlottie/src/vector/pixman/*.S']
# rapidjson's msinttypes/ are MSVC-only shims (guarded by _MSC_VER in
# rapidjson.h); on Apple clang they must not be compiled — the module build
# would otherwise hit their `#error "Use this header only with MSVC"`.
s.exclude_files = [
'rlottie/src/vector/pixman/*.S',
'rlottie/src/lottie/rapidjson/msinttypes/*.h',
]
s.public_header_files = 'rlottie/inc/*.h'
s.pod_target_xcconfig = {
# On Apple arm64 the compiler predefines __ARM_NEON__, which pulls in
# vdrawhelper_neon.cpp's hand-asm blitter calling pixman_composite_*_asm_neon
# — defined only in pixman-arm-neon-asm.S, which we can't assemble in the pod
# (excluded above) → undefined symbols at link. Drop the hand-asm path (like
# the CMake build does for 32-bit ARM) and let the C blitter compile; clang
# still auto-vectorizes it to NEON. No-op on x86_64 (macro undefined there).
'OTHER_CFLAGS' => '$(inherited) -U__ARM_NEON__',
'OTHER_CPLUSPLUSFLAGS' => '$(inherited) -U__ARM_NEON__',
'CLANG_CXX_LANGUAGE_STANDARD' => 'c++14',
'CLANG_CXX_LIBRARY' => 'libc++',
'GCC_ENABLE_CPP_EXCEPTIONS' => 'NO',