diff --git a/Cargo.lock b/Cargo.lock index 202cf37..76b1393 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1461,6 +1461,7 @@ dependencies = [ "pico-args", "rand 0.8.5", "reqwest", + "rlimit", "serde", "serde_json", "tokio", @@ -1949,6 +1950,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rlimit" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f35ee2729c56bb610f6dba436bf78135f728b7373bdffae2ec815b2d3eb98cc3" +dependencies = [ + "libc", +] + [[package]] name = "rust-embed" version = "8.11.0" diff --git a/ostp-client/src/config.rs b/ostp-client/src/config.rs index 1cd26e9..56f8e73 100644 --- a/ostp-client/src/config.rs +++ b/ostp-client/src/config.rs @@ -260,30 +260,55 @@ impl ClientConfig { // 3. Outbounds let mut outbounds = Vec::new(); - let server_full = json.get("server").and_then(|v| v.as_str()).unwrap_or("127.0.0.1:50000"); - let server_parts: Vec<&str> = server_full.split(':').collect(); - let server_host = server_parts.get(0).unwrap_or(&"127.0.0.1"); - let server_port = server_parts.get(1).unwrap_or(&"50000").parse::().unwrap_or(50000); - let access_key = json.get("access_key").and_then(|v| v.as_str()).unwrap_or(""); + let server_full = json.get("server").and_then(|v| v.as_str()) + .or_else(|| json.get("ostp").and_then(|o| o.get("server_addr")).and_then(|v| v.as_str())) + .unwrap_or("127.0.0.1:50000"); + + let access_key = json.get("access_key").and_then(|v| v.as_str()) + .or_else(|| json.get("ostp").and_then(|o| o.get("access_key")).and_then(|v| v.as_str())) + .unwrap_or(""); + let transport_type = json.get("transport").and_then(|t| t.get("mode").or(t.get("type"))).and_then(|v| v.as_str()).unwrap_or("udp"); let mux_enabled = json.get("mux").and_then(|m| m.get("enabled")).and_then(|v| v.as_bool()).unwrap_or(false); let mux_sessions = json.get("mux").and_then(|m| m.get("sessions")).and_then(|v| v.as_u64()).unwrap_or(1); - outbounds.push(serde_json::json!({ - "type": "ostp", - "tag": "proxy", - "server": server_host, - "port": server_port, - "access_key": access_key, - "transport": { - "type": transport_type - }, - "multiplex": { - "enabled": mux_enabled, - "sessions": mux_sessions - } - })); + let servers: Vec<&str> = server_full.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect(); + let mut ostp_tags = Vec::new(); + + for (i, server_str) in servers.iter().enumerate() { + let server_parts: Vec<&str> = server_str.split(':').collect(); + let server_host = server_parts.get(0).unwrap_or(&"127.0.0.1"); + let server_port = server_parts.get(1).unwrap_or(&"50000").parse::().unwrap_or(50000); + + let tag = if servers.len() > 1 { format!("proxy-{}", i) } else { "proxy".to_string() }; + ostp_tags.push(tag.clone()); + + outbounds.push(serde_json::json!({ + "type": "ostp", + "tag": tag, + "server": server_host, + "port": server_port, + "access_key": access_key, + "transport": { + "type": transport_type + }, + "multiplex": { + "enabled": mux_enabled, + "sessions": mux_sessions + } + })); + } + + if servers.len() > 1 { + outbounds.push(serde_json::json!({ + "type": "urltest", + "tag": "proxy", + "outbounds": ostp_tags, + "url": "http://cp.cloudflare.com", + "interval": "3m" + })); + } outbounds.push(serde_json::json!({ "type": "direct", diff --git a/ostp-flutter/android/app/src/main/kotlin/com/ospab/ostp_client/OstpVpnService.kt b/ostp-flutter/android/app/src/main/kotlin/com/ospab/ostp_client/OstpVpnService.kt index 3803894..b5de9e0 100644 --- a/ostp-flutter/android/app/src/main/kotlin/com/ospab/ostp_client/OstpVpnService.kt +++ b/ostp-flutter/android/app/src/main/kotlin/com/ospab/ostp_client/OstpVpnService.kt @@ -194,14 +194,19 @@ class OstpVpnService : VpnService() { val builder = Builder() .setSession("OSTP Tunnel") .addAddress("10.1.0.2", 24) - .addAddress("fd00:1:fd00:1:fd00:1:fd00:1", 128) + .addAddress("fd00::1", 128) .addRoute("0.0.0.0", 0) .addRoute("::", 0) - .addDnsServer(dnsServer) .setMtu(Math.max(1280, json.optJSONObject("ostp")?.optInt("mtu", 1140) ?: 1140)) + try { + builder.addDnsServer(dnsServer) + } catch (e: Throwable) { + Log.e("OstpVpnService", "Invalid user DNS server: $dnsServer", e) + try { builder.addDnsServer("1.1.1.1") } catch (e2: Throwable) {} + } + // Always add fallback IPv4 DNS servers - try { builder.addDnsServer("1.1.1.1") } catch (e: Throwable) {} try { builder.addDnsServer("8.8.8.8") } catch (e: Throwable) {} // NOTE: Do NOT add IPv6 DNS servers here — Android would send DNS // queries over IPv6, but our smoltcp TUN stack processes them as diff --git a/ostp-flutter/lib/models/ostp_profile.dart b/ostp-flutter/lib/models/ostp_profile.dart new file mode 100644 index 0000000..1edaf76 --- /dev/null +++ b/ostp-flutter/lib/models/ostp_profile.dart @@ -0,0 +1,49 @@ +import 'dart:convert'; + +class OstpProfile { + String id; + String name; + String serverAddr; + String accessKey; + String transportMode; + String stealthSni; + bool wss; + bool active; + + OstpProfile({ + required this.id, + required this.name, + required this.serverAddr, + required this.accessKey, + this.transportMode = 'udp', + this.stealthSni = '', + this.wss = false, + this.active = false, + }); + + Map toJson() { + return { + 'id': id, + 'name': name, + 'serverAddr': serverAddr, + 'accessKey': accessKey, + 'transportMode': transportMode, + 'stealthSni': stealthSni, + 'wss': wss, + 'active': active, + }; + } + + factory OstpProfile.fromJson(Map json) { + return OstpProfile( + id: json['id'] as String? ?? '', + name: json['name'] as String? ?? 'Unnamed Profile', + serverAddr: json['serverAddr'] as String? ?? '', + accessKey: json['accessKey'] as String? ?? '', + transportMode: json['transportMode'] as String? ?? 'udp', + stealthSni: json['stealthSni'] as String? ?? '', + wss: json['wss'] as bool? ?? false, + active: json['active'] as bool? ?? false, + ); + } +} diff --git a/ostp-flutter/lib/ui/home_screen.dart b/ostp-flutter/lib/ui/home_screen.dart index 7ed08a3..a4136de 100644 --- a/ostp-flutter/lib/ui/home_screen.dart +++ b/ostp-flutter/lib/ui/home_screen.dart @@ -8,6 +8,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:mobile_scanner/mobile_scanner.dart'; import 'package:flutter_svg/flutter_svg.dart'; import '../models/connection_state_enum.dart'; +import '../models/ostp_profile.dart'; import 'settings_screen.dart'; import 'logs_screen.dart'; import 'app_routing_screen.dart'; @@ -29,8 +30,7 @@ class _HomeScreenState extends State with TickerProviderStateMixin { Timer? _uptimeTimer; int _uptimeSecs = 0; - String _serverAddr = '127.0.0.1:443'; - String _accessKey = 'default_key'; + List _activeProfiles = []; String _download = '0 B'; String _upload = '0 B'; @@ -71,64 +71,134 @@ class _HomeScreenState extends State with TickerProviderStateMixin { void _loadSettings() { setState(() { - _serverAddr = widget.prefs.getString('server_addr') ?? '127.0.0.1:443'; - _accessKey = widget.prefs.getString('access_key') ?? ''; + final profilesJson = widget.prefs.getString('profiles_json'); + if (profilesJson != null && profilesJson.isNotEmpty) { + try { + final List decoded = jsonDecode(profilesJson); + final profiles = decoded.map((e) => OstpProfile.fromJson(e)).toList(); + _activeProfiles = profiles.where((p) => p.active).toList(); + } catch (e) { + debugPrint('Error loading profiles: $e'); + } + } else { + final oldServer = widget.prefs.getString('server_addr'); + final oldKey = widget.prefs.getString('access_key'); + if (oldServer != null && oldServer.isNotEmpty) { + final p = OstpProfile( + id: DateTime.now().millisecondsSinceEpoch.toString(), + name: 'Profile 1', + serverAddr: oldServer, + accessKey: oldKey ?? '', + transportMode: widget.prefs.getString('transport_mode') ?? 'udp', + stealthSni: widget.prefs.getString('stealth_sni') ?? '', + wss: widget.prefs.getBool('wss') ?? false, + active: true + ); + _activeProfiles = [p]; + widget.prefs.setString('profiles_json', jsonEncode([p.toJson()])); + } else { + _activeProfiles = []; + } + } }); _updateLatestConfigJson(); } void _updateLatestConfigJson() { - final exDomains = widget.prefs.getString('ex_domains') ?? ''; final exIps = widget.prefs.getString('ex_ips') ?? ''; final exProcesses = widget.prefs.getString('ex_processes') ?? ''; final debugMode = widget.prefs.getBool('debug_mode') ?? false; - final transportMode = widget.prefs.getString('transport_mode') ?? 'udp'; - final stealthSni = widget.prefs.getString('stealth_sni') ?? 'vk.com'; - final wss = widget.prefs.getBool('wss') ?? false; final mtu = widget.prefs.getString('mtu') ?? '1140'; final muxEnabled = widget.prefs.getBool('mux_enabled') ?? false; final muxSessions = widget.prefs.getString('mux_sessions') ?? '2'; + final tcpFrag = widget.prefs.getBool('tcp_fragmentation') ?? false; final dnsServer = widget.prefs.getString('dns_server'); final effectiveDnsServer = (dnsServer == null || dnsServer.isEmpty) ? '1.1.1.1' : dnsServer; final tunStack = 'ostp'; final appRoutingMode = widget.prefs.getString('app_routing_mode') ?? 'bypass'; final appRoutingPackages = widget.prefs.getStringList('app_routing_packages') ?? []; - final localBind = widget.prefs.getString('local_bind') ?? '127.0.0.1:1088'; + final localParts = localBind.split(':'); + final localListen = localParts.isNotEmpty ? localParts[0] : '127.0.0.1'; + final localPort = localParts.length > 1 ? (int.tryParse(localParts[1]) ?? 1088) : 1088; + + List> inbounds = [ + { + "type": "local_proxy", + "tag": "socks-in", + "protocol": "socks", + "listen": localListen, + "port": localPort + }, + { + "type": "tun", + "tag": "tun-in", + "auto_route": true, + "mtu": int.tryParse(mtu) ?? 1140 + } + ]; + + List> outbounds = []; + List ostpTags = []; + + for (int i = 0; i < _activeProfiles.length; i++) { + final p = _activeProfiles[i]; + final tag = _activeProfiles.length > 1 ? 'proxy-$i' : 'proxy'; + ostpTags.add(tag); + + final parts = p.serverAddr.split(':'); + final host = parts.isNotEmpty ? parts[0] : '127.0.0.1'; + final port = parts.length > 1 ? (int.tryParse(parts[1]) ?? 50000) : 50000; + + outbounds.add({ + "type": "ostp", + "tag": tag, + "server": host, + "port": port, + "access_key": p.accessKey, + "transport": { + "type": p.transportMode, + "stealth_sni": p.stealthSni, + "wss": p.wss, + "tcp_fragmentation": tcpFrag + }, + "multiplex": { + "enabled": muxEnabled, + "sessions": int.tryParse(muxSessions) ?? 2 + } + }); + } + + if (_activeProfiles.length > 1) { + outbounds.add({ + "type": "urltest", + "tag": "proxy", + "outbounds": ostpTags, + "url": "http://cp.cloudflare.com", + "interval": "3m" + }); + } + + outbounds.add({"type": "direct", "tag": "direct"}); + outbounds.add({"type": "block", "tag": "block"}); + final configMap = { - "mode": "client", - "debug": debugMode, - "ostp": { - "server_addr": _serverAddr, - "local_bind_addr": "0.0.0.0:0", - "access_key": _accessKey, - "handshake_timeout_ms": 10000, - "io_timeout_ms": 5000, - "mtu": int.tryParse(mtu) ?? 1140, + "version": "0.3.20", + "log": { + "level": debugMode ? "debug" : "info" }, - "local_proxy": { - "bind_addr": localBind, - "connect_timeout_ms": 15000, - }, - "transport": { - "mode": transportMode, - "stealth_sni": stealthSni, - "wss": wss, - "tcp_fragmentation": widget.prefs.getBool('tcp_fragmentation') ?? false, - }, - "multiplex": { - "enabled": muxEnabled, - "sessions": int.tryParse(muxSessions) ?? 2, - }, - "tun": { - "enable": true, - "stack": tunStack - }, - "exclusions": { - "domains": exDomains.split('\n').where((s) => s.trim().isNotEmpty).toList(), - "ips": exIps.split('\n').where((s) => s.trim().isNotEmpty).toList(), - "processes": exProcesses.split('\n').where((s) => s.trim().isNotEmpty).toList(), + "inbounds": inbounds, + "outbounds": outbounds, + "routing": { + "rules": [ + { + "domains": exDomains.split('\n').where((s) => s.trim().isNotEmpty).toList(), + "ips": exIps.split('\n').where((s) => s.trim().isNotEmpty).toList(), + "processes": exProcesses.split('\n').where((s) => s.trim().isNotEmpty).toList(), + "outbound": "direct" + } + ] }, "app_rules": { "mode": appRoutingMode, @@ -137,6 +207,7 @@ class _HomeScreenState extends State with TickerProviderStateMixin { "dns_server": effectiveDnsServer, "tun_stack": tunStack }; + widget.prefs.setString('latest_config_json', jsonEncode(configMap)); platform.invokeMethod('saveConfig', { "configJson": jsonEncode(configMap) @@ -154,9 +225,9 @@ class _HomeScreenState extends State with TickerProviderStateMixin { Future _toggleConnection() async { if (_state == ConnectionStateEnum.disconnected) { - if (_serverAddr.isEmpty || _accessKey.isEmpty) { + if (_activeProfiles.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Please configure Server and Key in Settings')), + const SnackBar(content: Text('Please select at least one profile in Settings')), ); return; } @@ -176,66 +247,12 @@ class _HomeScreenState extends State with TickerProviderStateMixin { final transportMode = widget.prefs.getString('transport_mode') ?? 'udp'; final stealthSni = widget.prefs.getString('stealth_sni') ?? 'vk.com'; final wss = widget.prefs.getBool('wss') ?? false; - final mtu = widget.prefs.getString('mtu') ?? '1140'; - final muxEnabled = widget.prefs.getBool('mux_enabled') ?? false; - final muxSessions = widget.prefs.getString('mux_sessions') ?? '2'; - final tunStack = 'ostp'; - - final appRoutingMode = widget.prefs.getString('app_routing_mode') ?? 'bypass'; - final appRoutingPackages = widget.prefs.getStringList('app_routing_packages') ?? []; - - final localBind = widget.prefs.getString('local_bind') ?? '127.0.0.1:1088'; - final configMap = { - "mode": "client", - "debug": debugMode, - "ostp": { - "server_addr": _serverAddr, - "local_bind_addr": "0.0.0.0:0", - "access_key": _accessKey, - "handshake_timeout_ms": 10000, - "io_timeout_ms": 5000, - "mtu": int.tryParse(mtu) ?? 1140, - }, - "local_proxy": { - "bind_addr": localBind, - "connect_timeout_ms": 15000, - }, - "transport": { - "mode": transportMode, - "stealth_sni": stealthSni, - "wss": wss, - "tcp_fragmentation": widget.prefs.getBool('tcp_fragmentation') ?? false, - }, - "multiplex": { - "enabled": muxEnabled, - "sessions": int.tryParse(muxSessions) ?? 2, - }, - "tun": { - "enable": true, - "stack": tunStack - }, - "exclusions": { - "domains": exDomains.split('\n').where((s) => s.trim().isNotEmpty).toList(), - "ips": exIps.split('\n').where((s) => s.trim().isNotEmpty).toList(), - "processes": exProcesses.split('\n').where((s) => s.trim().isNotEmpty).toList(), - }, - "app_rules": { - "mode": appRoutingMode, - "packages": appRoutingPackages, - }, - "dns_server": dnsServer, - "tun_stack": tunStack - }; - - widget.prefs.setString('latest_config_json', jsonEncode(configMap)); - + _updateLatestConfigJson(); + final configStr = widget.prefs.getString('latest_config_json') ?? '{}'; try { - await platform.invokeMethod('saveConfig', { - "configJson": jsonEncode(configMap) - }); await platform.invokeMethod('startTunnel', { - "configJson": jsonEncode(configMap) + "configJson": configStr }); bool started = false; @@ -305,9 +322,9 @@ class _HomeScreenState extends State with TickerProviderStateMixin { {'t': 'uot', 'w': false, 'r': true}, ]; - if (_serverAddr.isEmpty || _accessKey.isEmpty) { + if (_activeProfiles.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Please configure Server and Key first')), + const SnackBar(content: Text('Please select at least one profile first')), ); return; } @@ -319,10 +336,14 @@ class _HomeScreenState extends State with TickerProviderStateMixin { SnackBar(content: Text('Testing: ${mode['t']} | WSS: ${mode['w']} | XTLS: ${mode['r']} | MTU: $mtu'), duration: const Duration(seconds: 2)), ); - // Update prefs + // Update prefs and active profile await widget.prefs.setString('mtu', mtu.toString()); - await widget.prefs.setString('transport_mode', mode['t'] as String); - await widget.prefs.setBool('wss', mode['w'] as bool); + setState(() { + for (var p in _activeProfiles) { + p.transportMode = mode['t'] as String; + p.wss = mode['w'] as bool; + } + }); _updateLatestConfigJson(); setState(() { @@ -773,7 +794,7 @@ class _HomeScreenState extends State with TickerProviderStateMixin { const Icon(Icons.dns_rounded, size: 18, color: Colors.white70), const SizedBox(width: 10), Text( - _serverAddr, + _activeProfiles.isNotEmpty ? _activeProfiles.map((e)=>e.name).join(', ') : 'No profile selected', style: const TextStyle( fontFamily: 'monospace', fontSize: 15, diff --git a/ostp-flutter/lib/ui/settings_screen.dart b/ostp-flutter/lib/ui/settings_screen.dart index 8615e90..8d0cfbf 100644 --- a/ostp-flutter/lib/ui/settings_screen.dart +++ b/ostp-flutter/lib/ui/settings_screen.dart @@ -10,9 +10,8 @@ import 'app_routing_screen.dart'; import 'logs_screen.dart'; import 'qr_scanner_screen.dart'; import 'package:qr_flutter/qr_flutter.dart'; -import 'package:http/http.dart' as http; import 'package:url_launcher/url_launcher.dart'; -import 'package:package_info_plus/package_info_plus.dart'; +import '../models/ostp_profile.dart'; class SettingsScreen extends StatefulWidget { final SharedPreferences prefs; @@ -23,10 +22,7 @@ class SettingsScreen extends StatefulWidget { } class _SettingsScreenState extends State { - late TextEditingController _importCtrl; - late TextEditingController _serverCtrl; late TextEditingController _localBindCtrl; - late TextEditingController _keyCtrl; late TextEditingController _dnsCtrl; late TextEditingController _mtuCtrl; late TextEditingController _domainsCtrl; @@ -35,60 +31,58 @@ class _SettingsScreenState extends State { late TextEditingController _dnsDomainCtrl; late TextEditingController _pbkCtrl; late TextEditingController _sidCtrl; - - bool _obscureKey = true; - bool _debugMode = false; - late TextEditingController _dnsRegionCtrl; - String _transportMode = 'udp'; // 'udp' | 'uot' - String _tunStack = 'ostp'; // 'system' | 'ostp' - bool _muxEnabled = false; late TextEditingController _muxSessionsCtrl; - bool _isCheckingUpdates = false; + bool _debugMode = false; + String _tunStack = 'ostp'; + bool _muxEnabled = false; bool _tcpFragmentation = false; + List _profiles = []; + @override void initState() { super.initState(); - _importCtrl = TextEditingController(); _loadSettings(); } void _loadSettings() { - _serverCtrl = TextEditingController(text: widget.prefs.getString('server_addr') ?? ''); _localBindCtrl = TextEditingController(text: widget.prefs.getString('local_bind') ?? '127.0.0.1:1088'); - _keyCtrl = TextEditingController(text: widget.prefs.getString('access_key') ?? ''); _dnsCtrl = TextEditingController(text: widget.prefs.getString('dns_server') ?? ''); _mtuCtrl = TextEditingController(text: widget.prefs.getString('mtu') ?? '1140'); - _transportMode = widget.prefs.getString('transport_mode') ?? 'udp'; _tcpFragmentation = widget.prefs.getBool('tcp_fragmentation') ?? false; _domainsCtrl = TextEditingController(text: widget.prefs.getString('ex_domains') ?? ''); _ipsCtrl = TextEditingController(text: widget.prefs.getString('ex_ips') ?? ''); _processesCtrl = TextEditingController(text: widget.prefs.getString('ex_processes') ?? ''); _dnsDomainCtrl = TextEditingController(text: widget.prefs.getString('dns_domain') ?? ''); - _dnsRegionCtrl = TextEditingController(text: widget.prefs.getString('dns_region') ?? '1.1.1.1'); _pbkCtrl = TextEditingController(text: widget.prefs.getString('tun_pbk') ?? ''); _sidCtrl = TextEditingController(text: widget.prefs.getString('sid') ?? ''); _tunStack = widget.prefs.getString('tun_stack') ?? 'ostp'; _debugMode = widget.prefs.getBool('debug_mode') ?? false; _muxEnabled = widget.prefs.getBool('mux_enabled') ?? false; _muxSessionsCtrl = TextEditingController(text: widget.prefs.getString('mux_sessions') ?? '2'); + + final profilesJson = widget.prefs.getString('profiles_json'); + if (profilesJson != null && profilesJson.isNotEmpty) { + try { + final List decoded = jsonDecode(profilesJson); + _profiles = decoded.map((e) => OstpProfile.fromJson(e)).toList(); + } catch (e) { + debugPrint('Error loading profiles: $e'); + } + } } @override void dispose() { _saveSettings(); - _importCtrl.dispose(); - _serverCtrl.dispose(); _localBindCtrl.dispose(); - _keyCtrl.dispose(); _dnsCtrl.dispose(); _mtuCtrl.dispose(); _domainsCtrl.dispose(); _ipsCtrl.dispose(); _processesCtrl.dispose(); _dnsDomainCtrl.dispose(); - _dnsRegionCtrl.dispose(); _pbkCtrl.dispose(); _sidCtrl.dispose(); _muxSessionsCtrl.dispose(); @@ -96,12 +90,9 @@ class _SettingsScreenState extends State { } void _saveSettings() { - widget.prefs.setString('server_addr', _serverCtrl.text.trim()); widget.prefs.setString('local_bind', _localBindCtrl.text.trim()); - widget.prefs.setString('access_key', _keyCtrl.text.trim()); widget.prefs.setString('dns_server', _dnsCtrl.text.trim()); widget.prefs.setString('mtu', _mtuCtrl.text.trim()); - widget.prefs.setString('transport_mode', _transportMode); widget.prefs.setBool('tcp_fragmentation', _tcpFragmentation); widget.prefs.setString('ex_domains', _domainsCtrl.text.trim()); widget.prefs.setString('ex_ips', _ipsCtrl.text.trim()); @@ -109,38 +100,221 @@ class _SettingsScreenState extends State { widget.prefs.setBool('debug_mode', _debugMode); widget.prefs.setString('tun_stack', _tunStack); widget.prefs.setString('dns_domain', _dnsDomainCtrl.text.trim()); - widget.prefs.setString('dns_region', _dnsRegionCtrl.text.trim()); widget.prefs.setString('tun_pbk', _pbkCtrl.text.trim()); widget.prefs.setString('sid', _sidCtrl.text.trim()); widget.prefs.setBool('mux_enabled', _muxEnabled); widget.prefs.setString('mux_sessions', _muxSessionsCtrl.text.trim()); + + final profilesJson = jsonEncode(_profiles.map((e) => e.toJson()).toList()); + widget.prefs.setString('profiles_json', profilesJson); } - Widget _buildTextField(String label, TextEditingController controller, {String? hint, bool isPassword = false, int maxLines = 1, bool isMono = false}) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(label, style: const TextStyle(color: Colors.white54, fontSize: 13, fontWeight: FontWeight.bold, letterSpacing: 1.0)), - const SizedBox(height: 10), - TextField( - controller: controller, - obscureText: isPassword && _obscureKey, - maxLines: maxLines, - style: TextStyle(fontSize: 16, fontFamily: isMono ? 'monospace' : 'Inter'), - decoration: InputDecoration( - hintText: hint, - hintStyle: const TextStyle(color: Colors.white30), - filled: true, - fillColor: Theme.of(context).colorScheme.surface, - border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none), - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), - suffixIcon: isPassword ? IconButton( - icon: Icon(_obscureKey ? Icons.visibility : Icons.visibility_off, color: Colors.white54), - onPressed: () => setState(() => _obscureKey = !_obscureKey), - ) : null, - ), + + void _importFromLink(String link) { + if (link.isEmpty) return; + try { + if (!link.startsWith('ostp://')) { + throw Exception('Link must start with ostp://'); + } + final uri = Uri.parse(link); + final key = Uri.decodeComponent(uri.userInfo); + final host = uri.authority.replaceFirst(uri.userInfo + '@', ''); + if (key.isEmpty || host.isEmpty) { + throw Exception('Incomplete link parameters'); + } + final type = uri.queryParameters['type']; + final transportMode = type == 'tcp' || type == 'http' ? 'uot' : (type == 'dns' ? 'dns' : 'udp'); + final name = uri.queryParameters['name'] ?? host; + + setState(() { + _profiles.add(OstpProfile( + id: DateTime.now().millisecondsSinceEpoch.toString(), + name: name, + serverAddr: host, + accessKey: key, + transportMode: transportMode, + )); + _saveSettings(); + }); + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Imported successfully'))); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: ${e.toString()}'))); + } + } + + void _showAddProfileMenu() { + showModalBottomSheet( + context: context, + backgroundColor: Theme.of(context).colorScheme.surface, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))), + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.qr_code_scanner, color: Colors.white), + title: const Text('Import from QR code'), + onTap: () async { + Navigator.pop(context); + final result = await Navigator.push( + context, + MaterialPageRoute(builder: (context) => const QRScannerScreen()), + ); + if (result != null && result is String && result.startsWith('ostp://')) { + _importFromLink(result); + } + }, + ), + ListTile( + leading: const Icon(Icons.link, color: Colors.white), + title: const Text('Import from link'), + onTap: () { + Navigator.pop(context); + _showImportLinkDialog(); + }, + ), + ListTile( + leading: const Icon(Icons.edit, color: Colors.white), + title: const Text('Insert manually'), + onTap: () { + Navigator.pop(context); + _showEditProfileDialog(null); + }, + ), + ], ), - const SizedBox(height: 24), - ], + ), + ); + } + + void _showImportLinkDialog() { + final TextEditingController linkCtrl = TextEditingController(); + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Import Link'), + backgroundColor: Theme.of(context).colorScheme.surface, + content: TextField( + controller: linkCtrl, + decoration: const InputDecoration(hintText: 'ostp://...'), + autofocus: true, + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')), + TextButton( + onPressed: () { + Navigator.pop(context); + _importFromLink(linkCtrl.text.trim()); + }, + child: const Text('Import') + ), + ], + ), + ); + } + + void _showEditProfileDialog(OstpProfile? profile) { + final isNew = profile == null; + final nameCtrl = TextEditingController(text: profile?.name ?? ''); + final serverCtrl = TextEditingController(text: profile?.serverAddr ?? ''); + final keyCtrl = TextEditingController(text: profile?.accessKey ?? ''); + String transportMode = profile?.transportMode ?? 'udp'; + + showDialog( + context: context, + builder: (context) { + return StatefulBuilder( + builder: (context, setDialogState) => AlertDialog( + title: Text(isNew ? 'New Profile' : 'Edit Profile'), + backgroundColor: Theme.of(context).colorScheme.surface, + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField(controller: nameCtrl, decoration: const InputDecoration(labelText: 'Name')), + TextField(controller: serverCtrl, decoration: const InputDecoration(labelText: 'Server Address (host:port)')), + TextField(controller: keyCtrl, decoration: const InputDecoration(labelText: 'Access Key')), + const SizedBox(height: 16), + DropdownButtonFormField( + value: transportMode, + decoration: const InputDecoration(labelText: 'Transport'), + items: const [ + DropdownMenuItem(value: 'udp', child: Text('UDP')), + DropdownMenuItem(value: 'uot', child: Text('TCP (UoT)')), + ], + onChanged: (v) { + if (v != null) setDialogState(() => transportMode = v); + }, + ), + ], + ), + ), + actions: [ + if (!isNew) + TextButton( + onPressed: () { + setState(() { + _profiles.removeWhere((p) => p.id == profile.id); + _saveSettings(); + }); + Navigator.pop(context); + }, + child: const Text('Delete', style: TextStyle(color: Colors.redAccent)), + ), + TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')), + TextButton( + onPressed: () { + setState(() { + if (isNew) { + _profiles.add(OstpProfile( + id: DateTime.now().millisecondsSinceEpoch.toString(), + name: nameCtrl.text.trim(), + serverAddr: serverCtrl.text.trim(), + accessKey: keyCtrl.text.trim(), + transportMode: transportMode, + active: true, + )); + } else { + profile.name = nameCtrl.text.trim(); + profile.serverAddr = serverCtrl.text.trim(); + profile.accessKey = keyCtrl.text.trim(); + profile.transportMode = transportMode; + } + _saveSettings(); + }); + Navigator.pop(context); + }, + child: const Text('Save') + ), + ], + ), + ); + }, + ); + } + + Widget _buildTextField(String label, TextEditingController controller, {String? hint, int maxLines = 1}) { + return Padding( + padding: const EdgeInsets.only(bottom: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: const TextStyle(color: Colors.white54, fontSize: 13, fontWeight: FontWeight.bold, letterSpacing: 1.0)), + const SizedBox(height: 10), + TextField( + controller: controller, + maxLines: maxLines, + style: const TextStyle(fontSize: 16), + decoration: InputDecoration( + hintText: hint, + hintStyle: const TextStyle(color: Colors.white30), + filled: true, + fillColor: Theme.of(context).colorScheme.surface, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + ), + ), + ], + ), ); } @@ -163,12 +337,10 @@ class _SettingsScreenState extends State { Switch( value: value, onChanged: (v) { - onChanged(v); + setState(() => onChanged(v)); _saveSettings(); }, activeColor: Theme.of(context).colorScheme.secondary, - activeTrackColor: Theme.of(context).colorScheme.secondary.withOpacity(0.3), - inactiveTrackColor: Colors.white10, ) ], ), @@ -188,89 +360,57 @@ class _SettingsScreenState extends State { ), actions: [ IconButton( - icon: const Icon(Icons.share_rounded), - tooltip: 'Share Config', - onPressed: _showShareModal, + icon: const Icon(Icons.add_rounded), + tooltip: 'Add Profile', + onPressed: _showAddProfileMenu, ), - IconButton( - icon: const Icon(Icons.qr_code_scanner_rounded), - onPressed: () async { - final result = await Navigator.push( - context, - MaterialPageRoute(builder: (context) => const QRScannerScreen()), - ); - if (result != null && result is String && result.startsWith('ostp://')) { - setState(() { - _importCtrl.text = result; - }); - } - }, - ) ], ), body: ListView( padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), children: [ - // Quick Import Row - Row( - children: [ - Expanded( - child: TextField( - controller: _importCtrl, - decoration: InputDecoration( - hintText: 'Paste ostp:// share link...', - hintStyle: const TextStyle(color: Colors.white30, fontSize: 14), - filled: true, - fillColor: Colors.white.withOpacity(0.05), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(20), borderSide: BorderSide.none), - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), - ), - ), + const Text('PROFILES', style: TextStyle(color: Colors.white54, fontSize: 13, fontWeight: FontWeight.bold, letterSpacing: 1.0)), + const SizedBox(height: 16), + if (_profiles.isEmpty) + Center( + child: Padding( + padding: const EdgeInsets.all(32.0), + child: Text('Create a new profile', style: TextStyle(color: Colors.white.withOpacity(0.5), fontSize: 18)), ), - const SizedBox(width: 12), - ElevatedButton( - onPressed: () { - final raw = _importCtrl.text.trim(); - if (raw.isEmpty) return; - try { - if (!raw.startsWith('ostp://')) { - throw Exception('Link must start with ostp://'); - } - final uri = Uri.parse(raw); - final key = Uri.decodeComponent(uri.userInfo); - final host = uri.authority.replaceFirst(uri.userInfo + '@', ''); - if (key.isEmpty || host.isEmpty) { - throw Exception('Incomplete link parameters'); - } + ) + else + ..._profiles.map((p) => Card( + color: Theme.of(context).colorScheme.surface, + margin: const EdgeInsets.only(bottom: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: ListTile( + leading: Checkbox( + value: p.active, + onChanged: (val) { setState(() { - _serverCtrl.text = host; - _keyCtrl.text = key; - _dnsDomainCtrl.text = uri.queryParameters['domain'] ?? ''; - _dnsRegionCtrl.text = uri.queryParameters['resolver'] ?? '1.1.1.1'; - - final type = uri.queryParameters['type']; - _transportMode = type == 'tcp' || type == 'http' ? 'uot' : (type == 'dns' ? 'dns' : 'udp'); - _importCtrl.clear(); - + p.active = val ?? false; _saveSettings(); }); - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Imported successfully'))); - } catch (e) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: ${e.toString()}'))); - } - }, - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), - backgroundColor: Colors.white, - foregroundColor: Colors.black, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + }, ), - child: const Text('Import', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.black)), - ) - ], - ), + title: Text(p.name, style: const TextStyle(fontWeight: FontWeight.bold)), + subtitle: Text('${p.serverAddr} (${p.transportMode.toUpperCase()})', style: const TextStyle(fontSize: 12)), + trailing: IconButton( + icon: const Icon(Icons.edit, size: 20, color: Colors.white54), + onPressed: () => _showEditProfileDialog(p), + ), + onTap: () { + setState(() { + p.active = !p.active; + _saveSettings(); + }); + }, + ), + )).toList(), - const SizedBox(height: 30), + const SizedBox(height: 32), + const Text('CLIENT SETTINGS', style: TextStyle(color: Colors.white54, fontSize: 13, fontWeight: FontWeight.bold, letterSpacing: 1.0)), + const SizedBox(height: 16), Container( padding: const EdgeInsets.all(24), @@ -282,489 +422,44 @@ class _SettingsScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildTextField('Server Address', _serverCtrl, hint: 'host:port'), + _buildToggle('MUX (Multiplexing)', 'Multiple sessions over single connection', _muxEnabled, (v) => _muxEnabled = v), + if (_muxEnabled) + _buildTextField('MUX Sessions', _muxSessionsCtrl, hint: 'e.g. 2, 4, 8'), + + _buildToggle('TCP Fragmentation', 'Break TLS Hello into small pieces', _tcpFragmentation, (v) => _tcpFragmentation = v), + _buildToggle('Debug Mode', 'Verbose logging', _debugMode, (v) => _debugMode = v), + _buildTextField('Local Proxy Bind', _localBindCtrl, hint: '127.0.0.1:1088'), - _buildTextField('Access Key', _keyCtrl, hint: 'Secure access key', isPassword: true), _buildTextField('Custom DNS Server', _dnsCtrl, hint: '1.1.1.1 (e.g. 8.8.8.8)'), _buildTextField('MTU (Packet Size)', _mtuCtrl, hint: '1140 (decrease if connection drops)'), - - // ── Transport Mode ─────────────────────────────────────── - const Text('Transport Mode', style: TextStyle(color: Colors.white54, fontSize: 13, fontWeight: FontWeight.bold, letterSpacing: 1.0)), - const SizedBox(height: 10), - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(12), - ), - child: Column( - children: [ - RadioListTile( - value: 'udp', - groupValue: _transportMode, - title: const Text('UDP (Default)', style: TextStyle(fontWeight: FontWeight.w600)), - subtitle: const Text('Fast, works on Wi-Fi and most networks', style: TextStyle(color: Colors.white54, fontSize: 12)), - activeColor: Theme.of(context).colorScheme.secondary, - onChanged: (v) => setState(() { _transportMode = v!; _saveSettings(); }), - ), - Divider(color: Colors.white.withOpacity(0.05), height: 1), - RadioListTile( - value: 'uot', - groupValue: _transportMode, - title: const Text('UoT (UDP-over-TCP)', style: TextStyle(fontWeight: FontWeight.w600)), - subtitle: const Text('Reliable on strict networks. Enables TCP DPI bypass.', style: TextStyle(color: Colors.white54, fontSize: 12)), - activeColor: Theme.of(context).colorScheme.primary, - onChanged: (v) => setState(() { _transportMode = v!; _saveSettings(); }), - ), - if (_transportMode == 'uot') - Padding( - padding: const EdgeInsets.only(left: 16.0, right: 8.0, bottom: 8.0), - child: SwitchListTile( - title: const Text('TCP Fragmentation', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500)), - subtitle: const Text('Bypass DPI by chunking handshake (Zapret style)', style: TextStyle(fontSize: 12, color: Colors.white54)), - value: _tcpFragmentation, - activeColor: Theme.of(context).colorScheme.primary, - onChanged: (v) => setState(() { _tcpFragmentation = v; _saveSettings(); }), - ), - ), - Divider(color: Colors.white.withOpacity(0.05), height: 1), - RadioListTile( - value: 'dns', - groupValue: _transportMode, - title: const Text('DNS Tunnel', style: TextStyle(fontWeight: FontWeight.w600)), - subtitle: const Text('Very slow, but works under strict DPI blocks', style: TextStyle(color: Colors.orangeAccent, fontSize: 12)), - activeColor: Colors.orangeAccent, - onChanged: (v) => setState(() { _transportMode = v!; _saveSettings(); }), - ), - ], + + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + icon: const Icon(Icons.route), + label: const Text('Configure Split Tunneling'), + onPressed: () { + Navigator.push(context, MaterialPageRoute(builder: (context) => AppRoutingScreen(prefs: widget.prefs))); + }, ), ), - const SizedBox(height: 16), - - // DNS Proxy parameters - AnimatedCrossFade( - duration: const Duration(milliseconds: 250), - crossFadeState: _transportMode == 'dns' ? CrossFadeState.showFirst : CrossFadeState.showSecond, - firstChild: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.orangeAccent.withOpacity(0.06), - borderRadius: BorderRadius.circular(16), - border: Border.all(color: Colors.orangeAccent.withOpacity(0.2)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Icon(Icons.dns, size: 16, color: Colors.orangeAccent), - const SizedBox(width: 8), - const Text('DNS Tunnel Settings', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.orangeAccent, fontSize: 14)), - ], - ), - const SizedBox(height: 4), - const Text( - 'Specify the domain pointing to your server. Details in Wiki.', - style: TextStyle(fontSize: 12, color: Colors.white38), - ), - const SizedBox(height: 16), - _buildTextField('Domain (Points to Server)', _dnsDomainCtrl, hint: 'tunnel.myvpn.com'), - const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: _buildTextField('DNS Resolver Server', _dnsRegionCtrl, hint: '1.1.1.1'), - ), - const SizedBox(width: 8), - Padding( - padding: const EdgeInsets.only(top: 24.0), - child: ElevatedButton( - onPressed: _showDnsProberDialog, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.orangeAccent.withOpacity(0.2), - foregroundColor: Colors.orangeAccent, - elevation: 0, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - ), - child: const Text('PROBER', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12)), - ), - ) - ], - ), - ], - ), - ), - secondChild: const SizedBox.shrink(), - ), - - const SizedBox(height: 16), - _buildToggle('Multiplexing (Mux)', 'Combine multiple TCP streams to bypass throttling', _muxEnabled, (v) => setState(() => _muxEnabled = v)), - AnimatedCrossFade( - duration: const Duration(milliseconds: 200), - crossFadeState: _muxEnabled ? CrossFadeState.showFirst : CrossFadeState.showSecond, - firstChild: Padding( - padding: const EdgeInsets.only(top: 12.0), - child: _buildTextField('Mux Sessions', _muxSessionsCtrl, hint: '4'), - ), - secondChild: const SizedBox.shrink(), - ), - - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded(child: _buildToggle('Debug Logs', 'Verbose output', _debugMode, (v) => setState(() => _debugMode = v))), - Padding( - padding: const EdgeInsets.only(bottom: 24.0, left: 10), - child: IconButton( - icon: const Icon(Icons.receipt_long_rounded), - color: Theme.of(context).colorScheme.primary, - tooltip: 'View Logs', - onPressed: () { - Navigator.push(context, MaterialPageRoute(builder: (context) => const LogsScreen())); - }, - ), - ), - ], - ), - - - const Padding( - padding: EdgeInsets.symmetric(vertical: 16), - child: Row( - children: [ - Text('Exclusions', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), - SizedBox(width: 10), - Text('one per line', style: TextStyle(fontSize: 13, color: Colors.white30)), - ], + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + icon: const Icon(Icons.article), + label: const Text('View Logs'), + onPressed: () { + Navigator.push(context, MaterialPageRoute(builder: (context) => const LogsScreen())); + }, ), ), - - _buildTextField('Bypass Domains', _domainsCtrl, hint: 'example.com\n*.google.com', maxLines: 3, isMono: true), - _buildTextField('Bypass IPs / CIDR', _ipsCtrl, hint: '192.168.1.0/24\n10.0.0.1', maxLines: 3, isMono: true), - - // Premium app routing trigger button - InkWell( - onTap: () { - Navigator.push( - context, - MaterialPageRoute(builder: (context) => AppRoutingScreen(prefs: widget.prefs)), - ); - }, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primary.withOpacity(0.08), - borderRadius: BorderRadius.circular(16), - border: Border.all(color: Theme.of(context).colorScheme.primary.withOpacity(0.2)), - ), - child: Row( - children: [ - Icon(Icons.apps_rounded, color: Theme.of(context).colorScheme.primary, size: 24), - const SizedBox(width: 16), - const Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Per-App Connection Rules', - style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Colors.white), - ), - SizedBox(height: 4), - Text( - 'Choose which apps bypass or use VPN', - style: TextStyle(fontSize: 13, color: Colors.white54), - ), - ], - ), - ), - const Icon(Icons.arrow_forward_ios_rounded, color: Colors.white54, size: 16), - ], - ), - ), - ), - const SizedBox(height: 10), ], ), - ), - - const SizedBox(height: 16), - - InkWell( - onTap: _isCheckingUpdates ? null : _checkForUpdates, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.02), - borderRadius: BorderRadius.circular(16), - border: Border.all(color: Colors.white.withOpacity(0.05)), - ), - child: Row( - children: [ - Icon(Icons.system_update_rounded, color: Colors.white70, size: 24), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Check for Updates', - style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Colors.white), - ), - SizedBox(height: 4), - Text( - _isCheckingUpdates ? 'Checking...' : 'Check latest release on GitHub', - style: TextStyle(fontSize: 13, color: Colors.white54), - ), - ], - ), - ), - if (_isCheckingUpdates) - const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white54), - ) - else - const Icon(Icons.arrow_forward_ios_rounded, color: Colors.white54, size: 16), - ], - ), - ), - ), - - const SizedBox(height: 40), + ) ], ), ); } - - String _generateShareUrl() { - final host = _serverCtrl.text.trim(); - final key = Uri.encodeComponent(_keyCtrl.text.trim()); - if (host.isEmpty || key.isEmpty) return ''; - - final queryParams = []; - if (_dnsDomainCtrl.text.trim().isNotEmpty) { - queryParams.add('domain=${Uri.encodeComponent(_dnsDomainCtrl.text.trim())}'); - } - final resolver = _dnsRegionCtrl.text.trim(); - if (resolver.isNotEmpty && resolver != '1.1.1.1') { - queryParams.add('resolver=${Uri.encodeComponent(resolver)}'); - } - if (_pbkCtrl.text.trim().isNotEmpty) { - queryParams.add('pbk=${Uri.encodeComponent(_pbkCtrl.text.trim())}'); - } - if (_sidCtrl.text.trim().isNotEmpty) { - queryParams.add('sid=${Uri.encodeComponent(_sidCtrl.text.trim())}'); - } - if (_transportMode != 'udp') { - queryParams.add('type=$_transportMode'); - } - - final queryString = queryParams.isEmpty ? '' : '?${queryParams.join('&')}'; - return 'ostp://$key@$host$queryString'; - } - - void _showShareModal() { - final url = _generateShareUrl(); - if (url.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Server Address and Access Key are required to share.'))); - return; - } - - showDialog( - context: context, - builder: (context) { - return AlertDialog( - backgroundColor: Theme.of(context).colorScheme.surface, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - title: const Text('Share Config', textAlign: TextAlign.center), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - ), - child: QrImageView( - data: url, - version: QrVersions.auto, - size: 200.0, - ), - ), - const SizedBox(height: 20), - ElevatedButton.icon( - onPressed: () { - Clipboard.setData(ClipboardData(text: url)); - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Copied to clipboard'))); - Navigator.pop(context); - }, - icon: const Icon(Icons.copy_rounded, color: Colors.black), - label: const Text('Copy Link', style: TextStyle(color: Colors.black, fontWeight: FontWeight.bold)), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.white, - foregroundColor: Colors.black, - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - ), - ) - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Close'), - ) - ], - ); - } - ); - } - - Future _showDnsProberDialog() async { - const channel = MethodChannel('com.ospab.ostp/vpn'); - showDialog( - context: context, - barrierDismissible: false, - builder: (context) { - return StatefulBuilder( - builder: (context, setModalState) { - return AlertDialog( - backgroundColor: Theme.of(context).colorScheme.surface, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - title: const Text('DNS Prober', textAlign: TextAlign.center), - content: FutureBuilder( - future: channel.invokeMethod('runDnsProber', {'domain': _dnsDomainCtrl.text.trim()}), - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return const Column( - mainAxisSize: MainAxisSize.min, - children: [ - CircularProgressIndicator(), - SizedBox(height: 16), - Text('Sending real tunnel probes...', style: TextStyle(color: Colors.white54, fontSize: 13), textAlign: TextAlign.center), - ], - ); - } - - if (snapshot.hasError || !snapshot.hasData) { - return Text('Error: ${snapshot.error}', style: const TextStyle(color: Colors.redAccent)); - } - - List results = []; - try { - results = jsonDecode(snapshot.data!); - } catch (_) {} - - if (results.isEmpty) { - return const Text('No results or all timed out.', style: TextStyle(color: Colors.redAccent)); - } - - return SizedBox( - width: double.maxFinite, - child: ListView.builder( - shrinkWrap: true, - itemCount: results.length, - itemBuilder: (context, index) { - final res = results[index]; - final name = res['name'] ?? ''; - final ip = res['ip'] ?? ''; - final latency = res['latency_ms']; - - final isBest = index == 0 && latency != null; - - return ListTile( - onTap: latency != null ? () { - setState(() { - _dnsRegionCtrl.text = ip; - _saveSettings(); - }); - Navigator.pop(context); - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('DNS set to $ip'))); - } : null, - title: Text('${isBest ? '⭐ ' : ''}$name', style: const TextStyle(fontSize: 14)), - subtitle: Text(ip, style: const TextStyle(fontSize: 12, color: Colors.white54)), - trailing: Text( - latency != null ? '$latency ms' : 'TIMEOUT', - style: TextStyle( - color: latency == null ? Colors.redAccent : (latency < 100 ? Colors.greenAccent : Colors.orangeAccent), - fontWeight: FontWeight.bold, - ), - ), - tileColor: isBest ? Colors.blueAccent.withOpacity(0.1) : null, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), - ); - }, - ), - ); - }, - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Close'), - ) - ], - ); - } - ); - } - ); - } - - Future _checkForUpdates() async { - if (_isCheckingUpdates) return; - setState(() { _isCheckingUpdates = true; }); - try { - final packageInfo = await PackageInfo.fromPlatform(); - final currentVersion = packageInfo.version; - - final response = await http.get(Uri.parse('https://api.github.com/repos/ospab/ostp/releases/latest')); - if (response.statusCode == 200) { - final data = json.decode(response.body); - final latestVersion = (data['tag_name'] as String).replaceAll('v', ''); - - final hasUpdate = latestVersion != currentVersion; - - if (!mounted) return; - showDialog( - context: context, - builder: (context) { - return AlertDialog( - backgroundColor: Theme.of(context).colorScheme.surface, - title: Text(hasUpdate ? 'Update Available!' : 'Up to Date'), - content: Text(hasUpdate - ? 'A new version ($latestVersion) is available on GitHub. You are currently running version $currentVersion.' - : 'You are running the latest version ($currentVersion).'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Close'), - ), - if (hasUpdate) - TextButton( - onPressed: () { - Navigator.pop(context); - final url = Uri.parse(data['html_url'] ?? 'https://github.com/ospab/ostp/releases/latest'); - launchUrl(url, mode: LaunchMode.externalApplication); - }, - child: const Text('Download'), - ) - ], - ); - } - ); - } else { - throw Exception('HTTP ${response.statusCode}'); - } - } catch (e) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error checking updates: $e'))); - } finally { - if (mounted) setState(() { _isCheckingUpdates = false; }); - } - } } - diff --git a/ostp-gui/src/index.html b/ostp-gui/src/index.html index bba1534..a86b760 100644 --- a/ostp-gui/src/index.html +++ b/ostp-gui/src/index.html @@ -156,23 +156,22 @@
-
- - -
- -
- -
- - + + +
+
+ PROFILES + +
+
+ +
+
+
+
CLIENT SETTINGS
@@ -184,14 +183,8 @@
-
- - -
+ +