fix(client): harden control plane and SOCKS

This commit is contained in:
Naveax committed 2026-09-03 22:44:42 +03:00
1 parent 0ed7bf27b5
commit 0692bb83e1
4 files changed
+372 -169

No files matched your search

+222 -32
View File
@@ -13,6 +13,7 @@ class HivemindService {
static const _ua = 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 ' static const _ua = 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 '
'(KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36'; '(KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36';
static const int _maxControlResponseBytes = 256 * 1024;
static const _pollBackoff = <Duration>[ static const _pollBackoff = <Duration>[
Duration(seconds: 1), Duration(seconds: 1),
Duration(seconds: 2), Duration(seconds: 2),
@@ -27,7 +28,58 @@ class HivemindService {
return http.get(uri, headers: {'User-Agent': _ua}).timeout(timeout); return http.get(uri, headers: {'User-Agent': _ua}).timeout(timeout);
} }
static void cancel() => _currentCallId++; static Future<http.Response> controlGet(
Uri uri, {
Duration timeout = const Duration(seconds: 5),
}) async {
final client = http.Client();
final request = http.Request('GET', uri)
..headers['User-Agent'] = _ua
..followRedirects = false;
Future<http.Response> read() async {
final streamed = await client.send(request);
final declaredLength = streamed.contentLength;
if (declaredLength != null && declaredLength > _maxControlResponseBytes) {
throw const FormatException('Control response is too large.');
}
final bytes = <int>[];
await for (final chunk in streamed.stream) {
if (bytes.length + chunk.length > _maxControlResponseBytes) {
throw const FormatException('Control response is too large.');
}
bytes.addAll(chunk);
}
return http.Response.bytes(
bytes,
streamed.statusCode,
headers: streamed.headers,
isRedirect: streamed.isRedirect,
persistentConnection: streamed.persistentConnection,
reasonPhrase: streamed.reasonPhrase,
request: request,
);
}
try {
return await read().timeout(
timeout,
onTimeout: () {
client.close();
throw TimeoutException('Control request timed out.', timeout);
},
);
} finally {
client.close();
}
}
static void cancel() {
_currentCallId++;
_expectedNonce = null;
}
static void setExpectedNonce(String nonce) { static void setExpectedNonce(String nonce) {
_expectedNonce = nonce; _expectedNonce = nonce;
@@ -37,13 +89,19 @@ class HivemindService {
void Function(int attempt, int total)? onAttempt, void Function(int attempt, int total)? onAttempt,
bool skipAdBypass = false, bool skipAdBypass = false,
}) async { }) async {
final deviceId = await CryptoService.getDeviceId(); // Capture the generation before the first await. Otherwise a disconnect
// that lands while secure storage is resolving the device ID can be lost
// and the cancelled call can resume network work with a fresh generation.
final callId = ++_currentCallId; final callId = ++_currentCallId;
final deviceId = await CryptoService.getDeviceId();
_throwIfCancelled(callId);
final nonce = _newNonce(); final nonce = _newNonce();
_expectedNonce = nonce; _expectedNonce = nonce;
if (!skipAdBypass) { if (!skipAdBypass) {
await _runConfiguredBypass(deviceId, nonce); await _runConfiguredBypass(deviceId, nonce);
_throwIfCancelled(callId);
} }
const maxAttempts = 5; const maxAttempts = 5;
@@ -53,6 +111,7 @@ class HivemindService {
try { try {
final session = await _fetchActiveSession(deviceId, nonce); final session = await _fetchActiveSession(deviceId, nonce);
_throwIfCancelled(callId);
if (session != null) { if (session != null) {
_expectedNonce = null; _expectedNonce = null;
return session.toVlessUrl(); return session.toVlessUrl();
@@ -73,7 +132,7 @@ class HivemindService {
static Future<bool> checkHealth() async { static Future<bool> checkHealth() async {
try { try {
final response = await directGet( final response = await controlGet(
_publicUrl('/health'), _publicUrl('/health'),
timeout: const Duration(seconds: 3), timeout: const Duration(seconds: 3),
); );
@@ -109,15 +168,15 @@ class HivemindService {
final statusUri = _publicUrl('/session/status').replace( final statusUri = _publicUrl('/session/status').replace(
queryParameters: {'device_id': deviceId}, queryParameters: {'device_id': deviceId},
); );
final response = await directGet(statusUri); final response = await controlGet(statusUri);
if (response.statusCode != 200) return null; if (response.statusCode != 200) return null;
final decoded = jsonDecode(response.body); final decoded = jsonDecode(response.body);
if (decoded is! Map<String, dynamic>) return null; if (decoded is! Map<String, dynamic>) return null;
final serverNonce = decoded['nonce']; final serverNonce = decoded['nonce'];
if (_expectedNonce != null && if (_expectedNonce != nonce ||
serverNonce is String && serverNonce is! String ||
serverNonce != nonce) { serverNonce != nonce) {
return null; return null;
} }
@@ -141,6 +200,13 @@ class HivemindService {
} }
class _HivemindSessionConfig { class _HivemindSessionConfig {
static final RegExp _uuidPattern = RegExp(
r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$',
caseSensitive: false,
);
static final RegExp _shortIdPattern =
RegExp(r'^[0-9a-f]{0,16}$', caseSensitive: false);
final String uuid; final String uuid;
final String host; final String host;
final int port; final int port;
@@ -162,50 +228,174 @@ class _HivemindSessionConfig {
}); });
factory _HivemindSessionConfig.fromJson(Map<String, dynamic> json) { factory _HivemindSessionConfig.fromJson(Map<String, dynamic> json) {
final uuid = _requiredString(json, 'vless_uuid'); final uuid = _requiredString(json, 'vless_uuid', maxLength: 64);
final host = _stringOr(json['vless_ip'], AppConfig.serverIp); if (!_uuidPattern.hasMatch(uuid)) {
final port = _portOr(json['vless_port'], 443); throw const FormatException('Invalid VLESS UUID');
final sni = _requiredString(json, 'reality_sni'); }
// The API may rotate credentials and Reality parameters, but it must never
// choose the tunnel destination. Keep that trust anchor compiled into the
// app so an API/domain compromise can cause denial of service, not redirect
// VPN traffic to an attacker-controlled server.
final pinnedHost = _validatedPublicIp(AppConfig.serverIp, 'serverIp');
final advertisedHost = json['vless_ip'];
if (advertisedHost != null) {
if (advertisedHost is! String || advertisedHost.trim() != pinnedHost) {
throw const FormatException(
'Session VLESS host does not match compiled server pin',
);
}
}
final host = pinnedHost;
final advertisedPort = json['vless_port'];
if (advertisedPort != null &&
(advertisedPort is! num || advertisedPort.toInt() != 443)) {
throw const FormatException('Session VLESS port does not match compiled pin');
}
const port = 443;
final sni = _validatedHost(
_requiredString(json, 'reality_sni', maxLength: 253),
'reality_sni',
);
final publicKey = _requiredString(json, 'reality_pbk', maxLength: 128);
final shortId = _stringOr(json['reality_sid'], '').trim();
if (!_shortIdPattern.hasMatch(shortId)) {
throw const FormatException('Invalid Reality short ID');
}
final fingerprint =
_boundedString(json['reality_fp'], AppConfig.realityFp, 32, 'reality_fp');
final path =
_boundedString(json['xhttp_path'], AppConfig.vlessPath, 2048, 'xhttp_path');
if (!path.startsWith('/')) {
throw const FormatException('Invalid XHTTP path');
}
return _HivemindSessionConfig( return _HivemindSessionConfig(
uuid: uuid, uuid: uuid,
host: host, host: host,
port: port, port: port,
publicKey: _stringOr(json['reality_pbk'], ''), publicKey: publicKey,
shortId: _stringOr(json['reality_sid'], ''), shortId: shortId,
sni: sni, sni: sni,
fingerprint: _stringOr(json['reality_fp'], AppConfig.realityFp), fingerprint: fingerprint,
path: _stringOr(json['xhttp_path'], AppConfig.vlessPath), path: path,
); );
} }
String toVlessUrl() { String toVlessUrl() {
return 'vless://$uuid@$host:$port' return Uri(
'?security=${AppConfig.vlessSecurity}' scheme: 'vless',
'&type=${AppConfig.vlessType}' userInfo: uuid,
'&path=$path' host: host,
'&pbk=${Uri.encodeComponent(publicKey)}' port: port,
'&sni=${Uri.encodeComponent(sni)}' queryParameters: <String, String>{
'&sid=${Uri.encodeComponent(shortId)}' 'security': AppConfig.vlessSecurity,
'&fp=${Uri.encodeComponent(fingerprint)}' 'type': AppConfig.vlessType,
'#Revolt VPN'; 'path': path,
'pbk': publicKey,
'sni': sni,
'sid': shortId,
'fp': fingerprint,
},
fragment: 'Revolt VPN',
).toString();
} }
static String _requiredString(Map<String, dynamic> json, String key) { static String _requiredString(
Map<String, dynamic> json,
String key, {
int maxLength = 4096,
}) {
final value = json[key]; final value = json[key];
if (value is String && value.trim().isNotEmpty) return value.trim(); if (value is! String) {
throw FormatException('Missing required session field: $key'); throw FormatException('Missing required session field: $key');
}
final trimmed = value.trim();
if (trimmed.isEmpty || trimmed.length > maxLength) {
throw FormatException('Invalid session field: $key');
}
return trimmed;
} }
static String _stringOr(Object? value, String fallback) { static String _stringOr(Object? value, String fallback) {
return value is String && value.isNotEmpty ? value : fallback; return value is String && value.trim().isNotEmpty ? value.trim() : fallback;
} }
static int _portOr(Object? value, int fallback) { static String _boundedString(
final parsed = value is num ? value.toInt() : fallback; Object? value,
if (parsed <= 0 || parsed > 65535) { String fallback,
throw const FormatException('Invalid VLESS port'); int maxLength,
String field,
) {
final result = _stringOr(value, fallback);
if (result.isEmpty || result.length > maxLength) {
throw FormatException('Invalid session field: $field');
} }
return parsed; return result;
}
static String _validatedHost(String input, String field) {
final value = input.trim();
if (value.isEmpty ||
value.length > 253 ||
value.contains(RegExp(r'[\s/@?#]'))) {
throw FormatException('Invalid session host: $field');
}
try {
final probe = Uri(scheme: 'https', host: value);
if (probe.host.isEmpty) {
throw FormatException('Invalid session host: $field');
}
} on FormatException {
throw FormatException('Invalid session host: $field');
}
return value;
}
static String _validatedPublicIp(String input, String field) {
final value = input.trim().toLowerCase();
if (_isPublicIpv4(value) || _isPublicIpv6(value)) return value;
throw FormatException('Invalid public IP pin: $field');
}
static bool _isPublicIpv4(String value) {
final parts = value.split('.');
if (parts.length != 4) return false;
final octets = <int>[];
for (final part in parts) {
if (part.isEmpty || (part.length > 1 && part.startsWith('0'))) return false;
final parsed = int.tryParse(part);
if (parsed == null || parsed < 0 || parsed > 255) return false;
octets.add(parsed);
}
final a = octets[0];
final b = octets[1];
if (a == 0 || a == 10 || a == 127 || a >= 224) return false;
if (a == 100 && b >= 64 && b <= 127) return false;
if (a == 169 && b == 254) return false;
if (a == 172 && b >= 16 && b <= 31) return false;
if (a == 192 && b == 168) return false;
if (a == 192 && b == 0 && octets[2] == 2) return false;
if (a == 198 && b == 51 && octets[2] == 100) return false;
if (a == 203 && b == 0 && octets[2] == 113) return false;
return true;
}
static bool _isPublicIpv6(String value) {
if (!value.contains(':') || value.contains('.')) return false;
try {
final parsed = Uri.parse('https://[$value]/');
if (parsed.host.isEmpty || !parsed.host.contains(':')) return false;
} on FormatException {
return false;
}
if (value == '::' || value == '::1') return false;
if (value.startsWith('fc') || value.startsWith('fd')) return false;
if (value.startsWith('fe8') || value.startsWith('fe9') || value.startsWith('fea') || value.startsWith('feb')) return false;
if (value.startsWith('ff')) return false;
if (value.startsWith('2001:db8:') || value == '2001:db8::') return false;
return true;
} }
} }
+60 -36
View File
@@ -20,13 +20,13 @@ abstract final class LocalSocksTester {
/// Fast local readiness probe used while starting the VPN. /// Fast local readiness probe used while starting the VPN.
/// ///
/// This stops after SOCKS5 authentication. Startup must not depend on an /// This stops after authenticated SOCKS5 negotiation. Startup must not depend
/// unrelated external host being reachable. /// on an unrelated external host being reachable.
static Future<LocalSocksTestResult> testListener({ static Future<LocalSocksTestResult> testListener({
String host = '127.0.0.1', String host = '127.0.0.1',
int port = 10807, required int port,
String? username, required String username,
String? password, required String password,
}) async { }) async {
final stopwatch = Stopwatch()..start(); final stopwatch = Stopwatch()..start();
Socket? socket; Socket? socket;
@@ -86,26 +86,40 @@ abstract final class LocalSocksTester {
} }
} }
/// Full user-facing test: authenticated local SOCKS handshake plus outbound /// Full user-facing test: authenticated local SOCKS handshake plus an
/// CONNECT. Credentials are optional so the helper remains usable for legacy /// outbound TCP CONNECT through the active ReVolt route.
/// no-auth diagnostic builds.
static Future<LocalSocksTestResult> test({ static Future<LocalSocksTestResult> test({
String host = '127.0.0.1', String host = '127.0.0.1',
int port = 10807, required int port,
String? username, required String username,
String? password, required String password,
String targetHost = 'paladinvpn.duckdns.org', String targetHost = 'example.com',
int targetPort = 443, int targetPort = 443,
}) async { }) async {
const totalTimeout = Duration(seconds: 8);
final stopwatch = Stopwatch()..start(); final stopwatch = Stopwatch()..start();
Socket? socket; Socket? socket;
_SocketReader? reader; _SocketReader? reader;
Duration remaining() {
final micros = totalTimeout.inMicroseconds - stopwatch.elapsedMicroseconds;
if (micros <= 0) throw TimeoutException('SOCKS5 test timed out.', totalTimeout);
return Duration(microseconds: micros);
}
try { try {
if (targetPort <= 0 || targetPort > 65535) {
return const LocalSocksTestResult(
ok: false,
latencyMs: null,
message: 'SOCKS5 test target is invalid.',
);
}
socket = await Socket.connect( socket = await Socket.connect(
host, host,
port, port,
timeout: const Duration(seconds: 3), timeout: remaining(),
); );
socket.setOption(SocketOption.tcpNoDelay, true); socket.setOption(SocketOption.tcpNoDelay, true);
reader = _SocketReader(socket); reader = _SocketReader(socket);
@@ -115,6 +129,7 @@ abstract final class LocalSocksTester {
socket, socket,
username: username, username: username,
password: password, password: password,
timeout: remaining(),
); );
if (!authenticated) { if (!authenticated) {
return const LocalSocksTestResult( return const LocalSocksTestResult(
@@ -145,7 +160,7 @@ abstract final class LocalSocksTester {
]); ]);
await socket.flush(); await socket.flush();
final replyHead = await reader.readExactly(4); final replyHead = await reader.readExactly(4, timeout: remaining());
if (replyHead[0] != 0x05 || replyHead[1] != 0x00) { if (replyHead[0] != 0x05 || replyHead[1] != 0x00) {
return const LocalSocksTestResult( return const LocalSocksTestResult(
ok: false, ok: false,
@@ -155,7 +170,7 @@ abstract final class LocalSocksTester {
); );
} }
await _consumeAddress(reader, replyHead[3]); await _consumeAddress(reader, replyHead[3], timeout: remaining());
stopwatch.stop(); stopwatch.stop();
return LocalSocksTestResult( return LocalSocksTestResult(
ok: true, ok: true,
@@ -189,23 +204,10 @@ abstract final class LocalSocksTester {
static Future<bool> _authenticate( static Future<bool> _authenticate(
_SocketReader reader, _SocketReader reader,
Socket socket, { Socket socket, {
required String? username, required String username,
required String? password, required String password,
Duration timeout = const Duration(seconds: 4), Duration timeout = const Duration(seconds: 4),
}) async { }) async {
final passwordAuth = username != null && password != null;
if ((username == null) != (password == null)) return false;
socket.add(passwordAuth
? const <int>[0x05, 0x01, 0x02]
: const <int>[0x05, 0x01, 0x00]);
await socket.flush();
final greeting = await reader.readExactly(2, timeout: timeout);
if (greeting[0] != 0x05) return false;
if (!passwordAuth) return greeting[1] == 0x00;
if (greeting[1] != 0x02) return false;
final userBytes = utf8.encode(username); final userBytes = utf8.encode(username);
final passBytes = utf8.encode(password); final passBytes = utf8.encode(password);
if (userBytes.isEmpty || if (userBytes.isEmpty ||
@@ -215,6 +217,15 @@ abstract final class LocalSocksTester {
return false; return false;
} }
// ReVolt never accepts unauthenticated local SOCKS. Advertising only RFC
// 1929 username/password auth makes a future credential-plumbing regression
// fail closed instead of silently falling back to method 0x00.
socket.add(const <int>[0x05, 0x01, 0x02]);
await socket.flush();
final greeting = await reader.readExactly(2, timeout: timeout);
if (greeting[0] != 0x05 || greeting[1] != 0x02) return false;
socket.add(<int>[ socket.add(<int>[
0x01, 0x01,
userBytes.length, userBytes.length,
@@ -229,18 +240,24 @@ abstract final class LocalSocksTester {
} }
static Future<void> _consumeAddress( static Future<void> _consumeAddress(
_SocketReader reader, int addressType) async { _SocketReader reader,
int addressType, {
required Duration timeout,
}) async {
if (addressType == 0x01) { if (addressType == 0x01) {
await reader.readExactly(4 + 2); await reader.readExactly(4 + 2, timeout: timeout);
return; return;
} }
if (addressType == 0x04) { if (addressType == 0x04) {
await reader.readExactly(16 + 2); await reader.readExactly(16 + 2, timeout: timeout);
return; return;
} }
if (addressType == 0x03) { if (addressType == 0x03) {
final length = (await reader.readExactly(1)).first; final length = (await reader.readExactly(1, timeout: timeout)).first;
await reader.readExactly(length + 2); if (length == 0) {
throw const FormatException('Invalid SOCKS5 domain length');
}
await reader.readExactly(length + 2, timeout: timeout);
return; return;
} }
throw const FormatException('Unsupported SOCKS5 address type'); throw const FormatException('Unsupported SOCKS5 address type');
@@ -257,8 +274,15 @@ class _SocketReader {
int count, { int count, {
Duration timeout = const Duration(seconds: 4), Duration timeout = const Duration(seconds: 4),
}) async { }) async {
final elapsed = Stopwatch()..start();
while (_buffer.length < count) { while (_buffer.length < count) {
final hasNext = await _iterator.moveNext().timeout(timeout); final remainingMicros = timeout.inMicroseconds - elapsed.elapsedMicroseconds;
if (remainingMicros <= 0) {
throw TimeoutException('Socket read timed out', timeout);
}
final hasNext = await _iterator.moveNext().timeout(
Duration(microseconds: remainingMicros),
);
if (!hasNext) { if (!hasNext) {
throw const SocketException('Socket closed early'); throw const SocketException('Socket closed early');
} }
+22 -33
View File
@@ -39,40 +39,29 @@ class SecureSocksSession {
final username = _token(16); final username = _token(16);
final password = _token(32); final password = _token(32);
final sourceInbounds = decoded['inbounds']; // ReVolt owns local ingress. Never accept listeners supplied by a remote
final inbounds = <dynamic>[]; // or imported Xray config; the client exposes exactly one authenticated
if (sourceInbounds is List) { // loopback SOCKS5 listener for the current ephemeral session.
for (final value in sourceInbounds) { decoded['inbounds'] = <Map<String, dynamic>>[
if (value is Map) { <String, dynamic>{
final protocol = value['protocol']?.toString().toLowerCase(); 'tag': inboundTag,
// ReVolt owns local proxy ingress on Android. Drop imported SOCKS/HTTP 'port': port,
// listeners so a stale no-auth proxy cannot remain reachable beside 'listen': '127.0.0.1',
// the authenticated session listener. 'protocol': 'socks',
if (protocol == 'socks' || protocol == 'http') continue; 'settings': <String, dynamic>{
} 'auth': 'password',
inbounds.add(value); 'udp': true,
} 'ip': '127.0.0.1',
} 'users': <Map<String, String>>[
<String, String>{'user': username, 'pass': password},
inbounds.add(<String, dynamic>{ ],
'tag': inboundTag, },
'port': port, 'sniffing': <String, dynamic>{
'listen': '127.0.0.1', 'enabled': true,
'protocol': 'socks', 'destOverride': <String>['http', 'tls'],
'settings': <String, dynamic>{ },
'auth': 'password',
'udp': true,
'ip': '127.0.0.1',
'users': <Map<String, String>>[
<String, String>{'user': username, 'pass': password},
],
}, },
'sniffing': <String, dynamic>{ ];
'enabled': true,
'destOverride': <String>['http', 'tls'],
},
});
decoded['inbounds'] = inbounds;
return SecureSocksSession._( return SecureSocksSession._(
port: port, port: port,
+68 -68
View File
@@ -1,15 +1,14 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:revoltvpn/logic/app_config.dart';
import 'package:revoltvpn/logic/app_colors.dart'; import 'package:revoltvpn/logic/app_colors.dart';
import 'package:revoltvpn/logic/app_config.dart';
enum UpdateStatus { upToDate, updateAvailable, checkFailed } import 'package:url_launcher/url_launcher.dart';
enum InstallSource { playStore, sideload } enum InstallSource { playStore, sideload }
@@ -40,10 +39,12 @@ class _ParsedVersion {
if (rawCore.isEmpty || rawCore.any((p) => int.tryParse(p) == null)) { if (rawCore.isEmpty || rawCore.any((p) => int.tryParse(p) == null)) {
return null; return null;
} }
final core = rawCore.map(int.parse).toList(growable: false);
final core = rawCore.map(int.parse).toList();
while (core.length < 3) { while (core.length < 3) {
core.add(0); core.add(0);
} }
final pre = prePart.isEmpty final pre = prePart.isEmpty
? const <String>[] ? const <String>[]
: prePart.split('.').where((e) => e.isNotEmpty).toList(growable: false); : prePart.split('.').where((e) => e.isNotEmpty).toList(growable: false);
@@ -93,10 +94,7 @@ class PlayStoreUpdater {
static Future<bool> open() async { static Future<bool> open() async {
final uri = _trustedHttpsUri(_url, const {'play.google.com'}); final uri = _trustedHttpsUri(_url, const {'play.google.com'});
if (uri == null) { if (uri == null) return false;
debugPrint('[Updater] Refusing invalid Play Store URL.');
return false;
}
try { try {
return await launchUrl(uri, mode: LaunchMode.externalApplication); return await launchUrl(uri, mode: LaunchMode.externalApplication);
} catch (e) { } catch (e) {
@@ -121,38 +119,24 @@ class GitHubUpdater {
final response = await http final response = await http
.get(apiUri, headers: {'Accept': 'application/vnd.github+json'}) .get(apiUri, headers: {'Accept': 'application/vnd.github+json'})
.timeout(const Duration(seconds: 8)); .timeout(const Duration(seconds: 8));
if (response.statusCode != 200) return null;
if (response.statusCode != 200) {
debugPrint('[Updater] GitHub API returned ${response.statusCode}');
return null;
}
final decoded = jsonDecode(response.body); final decoded = jsonDecode(response.body);
if (decoded is! Map<String, dynamic>) { if (decoded is! Map<String, dynamic>) return null;
debugPrint('[Updater] Invalid GitHub release payload.');
return null;
}
final htmlUrl = decoded['html_url']; final htmlUrl = decoded['html_url'];
if (htmlUrl is String && _cachedDownloadUrl = htmlUrl is String &&
_trustedHttpsUri(htmlUrl, const {'github.com'}) != null) { _trustedHttpsUri(htmlUrl, const {'github.com'}) != null
_cachedDownloadUrl = htmlUrl; ? htmlUrl
} else { : null;
_cachedDownloadUrl = null;
}
final rawTag = decoded['tag_name']; final rawTag = decoded['tag_name'];
if (rawTag is! String || rawTag.isEmpty || rawTag.length > 64) { if (rawTag is! String || rawTag.isEmpty || rawTag.length > 64) {
debugPrint('[Updater] Invalid release tag.');
return null; return null;
} }
final normalized = rawTag.startsWith('v') ? rawTag.substring(1) : rawTag; final normalized = rawTag.startsWith('v') ? rawTag.substring(1) : rawTag;
if (_ParsedVersion.tryParse(normalized) == null) { return _ParsedVersion.tryParse(normalized) == null ? null : normalized;
debugPrint('[Updater] Release tag is not a supported version: $rawTag');
return null;
}
return normalized;
} catch (e) { } catch (e) {
debugPrint('[Updater] GitHub API error: $e'); debugPrint('[Updater] GitHub API error: $e');
return null; return null;
@@ -162,11 +146,7 @@ class GitHubUpdater {
static Future<bool> open() async { static Future<bool> open() async {
final raw = _cachedDownloadUrl ?? AppConfig.githubReleasesUrl; final raw = _cachedDownloadUrl ?? AppConfig.githubReleasesUrl;
final uri = _trustedHttpsUri(raw, const {'github.com'}); final uri = _trustedHttpsUri(raw, const {'github.com'});
if (uri == null) { if (uri == null) return false;
debugPrint('[Updater] Refusing untrusted GitHub update URL.');
return false;
}
try { try {
return await launchUrl(uri, mode: LaunchMode.externalApplication); return await launchUrl(uri, mode: LaunchMode.externalApplication);
} catch (e) { } catch (e) {
@@ -179,36 +159,32 @@ class GitHubUpdater {
class Updater { class Updater {
Updater._(); Updater._();
static const MethodChannel _installerChannel =
MethodChannel('com.revoltvpn.app/installer');
static InstallSource? _cachedSource; static InstallSource? _cachedSource;
static Future<InstallSource> get installSource async { static Future<InstallSource> get installSource async {
if (_cachedSource != null) return _cachedSource!; if (_cachedSource != null) return _cachedSource!;
if (kIsWeb || !Platform.isAndroid) { if (kIsWeb || !Platform.isAndroid) {
_cachedSource = InstallSource.sideload; return _cachedSource = InstallSource.sideload;
return _cachedSource!;
} }
try { try {
final result = await MethodChannel('com.revoltvpn.app/installer') final result =
.invokeMethod<String>('getInstallerPackage'); await _installerChannel.invokeMethod<String>('getInstallerPackage');
_cachedSource = (result == 'com.android.vending') return _cachedSource = result == 'com.android.vending'
? InstallSource.playStore ? InstallSource.playStore
: InstallSource.sideload; : InstallSource.sideload;
} catch (e) { } catch (e) {
debugPrint('[Updater] Installer check failed: $e'); debugPrint('[Updater] Installer check failed: $e');
_cachedSource = InstallSource.sideload; return _cachedSource = InstallSource.sideload;
} }
return _cachedSource!;
} }
static int _compareVersions(String a, String b) { static int _compareVersions(String a, String b) {
final left = _ParsedVersion.tryParse(a); final left = _ParsedVersion.tryParse(a);
final right = _ParsedVersion.tryParse(b); final right = _ParsedVersion.tryParse(b);
if (left == null || right == null) { if (left == null || right == null) return 0;
// Invalid local metadata should not invent an ordering.
return 0;
}
return left.compareTo(right); return left.compareTo(right);
} }
@@ -218,11 +194,9 @@ class Updater {
} }
static Future<void> check(BuildContext context) { static Future<void> check(BuildContext context) {
final navigator = Navigator.of(context, rootNavigator: true);
final messenger = ScaffoldMessenger.of(context);
return checkWithHandles( return checkWithHandles(
navigator: navigator, navigator: Navigator.of(context, rootNavigator: true),
messenger: messenger, messenger: ScaffoldMessenger.of(context),
); );
} }
@@ -230,14 +204,46 @@ class Updater {
required NavigatorState navigator, required NavigatorState navigator,
required ScaffoldMessengerState messenger, required ScaffoldMessengerState messenger,
}) async { }) async {
final source = await installSource;
if (source == InstallSource.playStore) {
if (!navigator.mounted) return;
await showDialog<void>(
context: navigator.context,
builder: (ctx) => AlertDialog(
backgroundColor: AppColors.bgCard,
title: const Text(
'Updates are managed by Play Store',
style: TextStyle(color: AppColors.textWhite),
),
content: const Text(
'Open Play Store to check whether an update is available for this install.',
style: TextStyle(color: AppColors.slate70),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
Navigator.pop(ctx);
PlayStoreUpdater.open();
},
child: const Text(
'Open Play Store',
style: TextStyle(color: AppColors.accent),
),
),
],
),
);
return;
}
final info = await PackageInfo.fromPlatform(); final info = await PackageInfo.fromPlatform();
final localVersion = info.version; final localVersion = info.version;
if (localVersion.isEmpty || _ParsedVersion.tryParse(localVersion) == null) { if (localVersion.isEmpty || _ParsedVersion.tryParse(localVersion) == null) {
_showSnackBar( _showSnackBar(messenger, 'Could not determine app version', isError: true);
messenger,
'Could not determine app version',
isError: true,
);
return; return;
} }
@@ -252,12 +258,8 @@ class Updater {
return; return;
} }
final source = await installSource;
final storeName =
source == InstallSource.playStore ? 'Play Store' : 'GitHub';
if (!navigator.mounted) return; if (!navigator.mounted) return;
await showDialog<void>(
showDialog(
context: navigator.context, context: navigator.context,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
backgroundColor: AppColors.bgCard, backgroundColor: AppColors.bgCard,
@@ -266,7 +268,7 @@ class Updater {
style: TextStyle(color: AppColors.textWhite), style: TextStyle(color: AppColors.textWhite),
), ),
content: Text( content: Text(
'${_versionLabel(latestVersion, false)} is available on $storeName.\n' '${_versionLabel(latestVersion, false)} is available on GitHub.\n'
'You have ${_versionLabel(localVersion, true)}.', 'You have ${_versionLabel(localVersion, true)}.',
style: const TextStyle(color: AppColors.slate70), style: const TextStyle(color: AppColors.slate70),
), ),
@@ -281,13 +283,11 @@ class Updater {
TextButton( TextButton(
onPressed: () { onPressed: () {
Navigator.pop(ctx); Navigator.pop(ctx);
source == InstallSource.playStore GitHubUpdater.open();
? PlayStoreUpdater.open()
: GitHubUpdater.open();
}, },
child: Text( child: const Text(
'Open $storeName', 'Open GitHub',
style: const TextStyle(color: AppColors.accent), style: TextStyle(color: AppColors.accent),
), ),
), ),
], ],