Bump file descriptor limits to fix EMFILE errors

This commit is contained in:
ospab 2026-06-25 23:32:14 +03:00
parent da41289336
commit 922cf0b142
11 changed files with 696 additions and 749 deletions

10
Cargo.lock generated
View File

@ -1461,6 +1461,7 @@ dependencies = [
"pico-args", "pico-args",
"rand 0.8.5", "rand 0.8.5",
"reqwest", "reqwest",
"rlimit",
"serde", "serde",
"serde_json", "serde_json",
"tokio", "tokio",
@ -1949,6 +1950,15 @@ dependencies = [
"windows-sys 0.52.0", "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]] [[package]]
name = "rust-embed" name = "rust-embed"
version = "8.11.0" version = "8.11.0"

View File

@ -260,19 +260,33 @@ impl ClientConfig {
// 3. Outbounds // 3. Outbounds
let mut outbounds = Vec::new(); 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_full = json.get("server").and_then(|v| v.as_str())
let server_host = server_parts.get(0).unwrap_or(&"127.0.0.1"); .or_else(|| json.get("ostp").and_then(|o| o.get("server_addr")).and_then(|v| v.as_str()))
let server_port = server_parts.get(1).unwrap_or(&"50000").parse::<u16>().unwrap_or(50000); .unwrap_or("127.0.0.1:50000");
let access_key = json.get("access_key").and_then(|v| v.as_str()).unwrap_or("");
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 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_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); let mux_sessions = json.get("mux").and_then(|m| m.get("sessions")).and_then(|v| v.as_u64()).unwrap_or(1);
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::<u16>().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!({ outbounds.push(serde_json::json!({
"type": "ostp", "type": "ostp",
"tag": "proxy", "tag": tag,
"server": server_host, "server": server_host,
"port": server_port, "port": server_port,
"access_key": access_key, "access_key": access_key,
@ -284,6 +298,17 @@ impl ClientConfig {
"sessions": mux_sessions "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!({ outbounds.push(serde_json::json!({
"type": "direct", "type": "direct",

View File

@ -194,14 +194,19 @@ class OstpVpnService : VpnService() {
val builder = Builder() val builder = Builder()
.setSession("OSTP Tunnel") .setSession("OSTP Tunnel")
.addAddress("10.1.0.2", 24) .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.0.0.0", 0)
.addRoute("::", 0) .addRoute("::", 0)
.addDnsServer(dnsServer)
.setMtu(Math.max(1280, json.optJSONObject("ostp")?.optInt("mtu", 1140) ?: 1140)) .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 // 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) {} try { builder.addDnsServer("8.8.8.8") } catch (e: Throwable) {}
// NOTE: Do NOT add IPv6 DNS servers here — Android would send DNS // NOTE: Do NOT add IPv6 DNS servers here — Android would send DNS
// queries over IPv6, but our smoltcp TUN stack processes them as // queries over IPv6, but our smoltcp TUN stack processes them as

View File

@ -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<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'serverAddr': serverAddr,
'accessKey': accessKey,
'transportMode': transportMode,
'stealthSni': stealthSni,
'wss': wss,
'active': active,
};
}
factory OstpProfile.fromJson(Map<String, dynamic> 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,
);
}
}

View File

@ -8,6 +8,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:mobile_scanner/mobile_scanner.dart'; import 'package:mobile_scanner/mobile_scanner.dart';
import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_svg/flutter_svg.dart';
import '../models/connection_state_enum.dart'; import '../models/connection_state_enum.dart';
import '../models/ostp_profile.dart';
import 'settings_screen.dart'; import 'settings_screen.dart';
import 'logs_screen.dart'; import 'logs_screen.dart';
import 'app_routing_screen.dart'; import 'app_routing_screen.dart';
@ -29,8 +30,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
Timer? _uptimeTimer; Timer? _uptimeTimer;
int _uptimeSecs = 0; int _uptimeSecs = 0;
String _serverAddr = '127.0.0.1:443'; List<OstpProfile> _activeProfiles = [];
String _accessKey = 'default_key';
String _download = '0 B'; String _download = '0 B';
String _upload = '0 B'; String _upload = '0 B';
@ -71,64 +71,134 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
void _loadSettings() { void _loadSettings() {
setState(() { setState(() {
_serverAddr = widget.prefs.getString('server_addr') ?? '127.0.0.1:443'; final profilesJson = widget.prefs.getString('profiles_json');
_accessKey = widget.prefs.getString('access_key') ?? ''; if (profilesJson != null && profilesJson.isNotEmpty) {
try {
final List<dynamic> 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(); _updateLatestConfigJson();
} }
void _updateLatestConfigJson() { void _updateLatestConfigJson() {
final exDomains = widget.prefs.getString('ex_domains') ?? ''; final exDomains = widget.prefs.getString('ex_domains') ?? '';
final exIps = widget.prefs.getString('ex_ips') ?? ''; final exIps = widget.prefs.getString('ex_ips') ?? '';
final exProcesses = widget.prefs.getString('ex_processes') ?? ''; final exProcesses = widget.prefs.getString('ex_processes') ?? '';
final debugMode = widget.prefs.getBool('debug_mode') ?? false; 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 mtu = widget.prefs.getString('mtu') ?? '1140';
final muxEnabled = widget.prefs.getBool('mux_enabled') ?? false; final muxEnabled = widget.prefs.getBool('mux_enabled') ?? false;
final muxSessions = widget.prefs.getString('mux_sessions') ?? '2'; final muxSessions = widget.prefs.getString('mux_sessions') ?? '2';
final tcpFrag = widget.prefs.getBool('tcp_fragmentation') ?? false;
final dnsServer = widget.prefs.getString('dns_server'); final dnsServer = widget.prefs.getString('dns_server');
final effectiveDnsServer = (dnsServer == null || dnsServer.isEmpty) ? '1.1.1.1' : dnsServer; final effectiveDnsServer = (dnsServer == null || dnsServer.isEmpty) ? '1.1.1.1' : dnsServer;
final tunStack = 'ostp'; final tunStack = 'ostp';
final appRoutingMode = widget.prefs.getString('app_routing_mode') ?? 'bypass'; final appRoutingMode = widget.prefs.getString('app_routing_mode') ?? 'bypass';
final appRoutingPackages = widget.prefs.getStringList('app_routing_packages') ?? []; final appRoutingPackages = widget.prefs.getStringList('app_routing_packages') ?? [];
final localBind = widget.prefs.getString('local_bind') ?? '127.0.0.1:1088'; final localBind = widget.prefs.getString('local_bind') ?? '127.0.0.1:1088';
final configMap = { final localParts = localBind.split(':');
"mode": "client", final localListen = localParts.isNotEmpty ? localParts[0] : '127.0.0.1';
"debug": debugMode, final localPort = localParts.length > 1 ? (int.tryParse(localParts[1]) ?? 1088) : 1088;
"ostp": {
"server_addr": _serverAddr, List<Map<String, dynamic>> inbounds = [
"local_bind_addr": "0.0.0.0:0", {
"access_key": _accessKey, "type": "local_proxy",
"handshake_timeout_ms": 10000, "tag": "socks-in",
"io_timeout_ms": 5000, "protocol": "socks",
"mtu": int.tryParse(mtu) ?? 1140, "listen": localListen,
}, "port": localPort
"local_proxy": {
"bind_addr": localBind,
"connect_timeout_ms": 15000,
}, },
{
"type": "tun",
"tag": "tun-in",
"auto_route": true,
"mtu": int.tryParse(mtu) ?? 1140
}
];
List<Map<String, dynamic>> outbounds = [];
List<String> 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": { "transport": {
"mode": transportMode, "type": p.transportMode,
"stealth_sni": stealthSni, "stealth_sni": p.stealthSni,
"wss": wss, "wss": p.wss,
"tcp_fragmentation": widget.prefs.getBool('tcp_fragmentation') ?? false, "tcp_fragmentation": tcpFrag
}, },
"multiplex": { "multiplex": {
"enabled": muxEnabled, "enabled": muxEnabled,
"sessions": int.tryParse(muxSessions) ?? 2, "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 = {
"version": "0.3.20",
"log": {
"level": debugMode ? "debug" : "info"
}, },
"tun": { "inbounds": inbounds,
"enable": true, "outbounds": outbounds,
"stack": tunStack "routing": {
}, "rules": [
"exclusions": { {
"domains": exDomains.split('\n').where((s) => s.trim().isNotEmpty).toList(), "domains": exDomains.split('\n').where((s) => s.trim().isNotEmpty).toList(),
"ips": exIps.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(), "processes": exProcesses.split('\n').where((s) => s.trim().isNotEmpty).toList(),
"outbound": "direct"
}
]
}, },
"app_rules": { "app_rules": {
"mode": appRoutingMode, "mode": appRoutingMode,
@ -137,6 +207,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
"dns_server": effectiveDnsServer, "dns_server": effectiveDnsServer,
"tun_stack": tunStack "tun_stack": tunStack
}; };
widget.prefs.setString('latest_config_json', jsonEncode(configMap)); widget.prefs.setString('latest_config_json', jsonEncode(configMap));
platform.invokeMethod('saveConfig', { platform.invokeMethod('saveConfig', {
"configJson": jsonEncode(configMap) "configJson": jsonEncode(configMap)
@ -154,9 +225,9 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
Future<void> _toggleConnection() async { Future<void> _toggleConnection() async {
if (_state == ConnectionStateEnum.disconnected) { if (_state == ConnectionStateEnum.disconnected) {
if (_serverAddr.isEmpty || _accessKey.isEmpty) { if (_activeProfiles.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar( 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; return;
} }
@ -176,66 +247,12 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
final transportMode = widget.prefs.getString('transport_mode') ?? 'udp'; final transportMode = widget.prefs.getString('transport_mode') ?? 'udp';
final stealthSni = widget.prefs.getString('stealth_sni') ?? 'vk.com'; final stealthSni = widget.prefs.getString('stealth_sni') ?? 'vk.com';
final wss = widget.prefs.getBool('wss') ?? false; final wss = widget.prefs.getBool('wss') ?? false;
final mtu = widget.prefs.getString('mtu') ?? '1140'; _updateLatestConfigJson();
final muxEnabled = widget.prefs.getBool('mux_enabled') ?? false; final configStr = widget.prefs.getString('latest_config_json') ?? '{}';
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));
try { try {
await platform.invokeMethod('saveConfig', {
"configJson": jsonEncode(configMap)
});
await platform.invokeMethod('startTunnel', { await platform.invokeMethod('startTunnel', {
"configJson": jsonEncode(configMap) "configJson": configStr
}); });
bool started = false; bool started = false;
@ -305,9 +322,9 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
{'t': 'uot', 'w': false, 'r': true}, {'t': 'uot', 'w': false, 'r': true},
]; ];
if (_serverAddr.isEmpty || _accessKey.isEmpty) { if (_activeProfiles.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar( 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; return;
} }
@ -319,10 +336,14 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
SnackBar(content: Text('Testing: ${mode['t']} | WSS: ${mode['w']} | XTLS: ${mode['r']} | MTU: $mtu'), duration: const Duration(seconds: 2)), 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('mtu', mtu.toString());
await widget.prefs.setString('transport_mode', mode['t'] as String); setState(() {
await widget.prefs.setBool('wss', mode['w'] as bool); for (var p in _activeProfiles) {
p.transportMode = mode['t'] as String;
p.wss = mode['w'] as bool;
}
});
_updateLatestConfigJson(); _updateLatestConfigJson();
setState(() { setState(() {
@ -773,7 +794,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
const Icon(Icons.dns_rounded, size: 18, color: Colors.white70), const Icon(Icons.dns_rounded, size: 18, color: Colors.white70),
const SizedBox(width: 10), const SizedBox(width: 10),
Text( Text(
_serverAddr, _activeProfiles.isNotEmpty ? _activeProfiles.map((e)=>e.name).join(', ') : 'No profile selected',
style: const TextStyle( style: const TextStyle(
fontFamily: 'monospace', fontFamily: 'monospace',
fontSize: 15, fontSize: 15,

View File

@ -10,9 +10,8 @@ import 'app_routing_screen.dart';
import 'logs_screen.dart'; import 'logs_screen.dart';
import 'qr_scanner_screen.dart'; import 'qr_scanner_screen.dart';
import 'package:qr_flutter/qr_flutter.dart'; import 'package:qr_flutter/qr_flutter.dart';
import 'package:http/http.dart' as http;
import 'package:url_launcher/url_launcher.dart'; 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 { class SettingsScreen extends StatefulWidget {
final SharedPreferences prefs; final SharedPreferences prefs;
@ -23,10 +22,7 @@ class SettingsScreen extends StatefulWidget {
} }
class _SettingsScreenState extends State<SettingsScreen> { class _SettingsScreenState extends State<SettingsScreen> {
late TextEditingController _importCtrl;
late TextEditingController _serverCtrl;
late TextEditingController _localBindCtrl; late TextEditingController _localBindCtrl;
late TextEditingController _keyCtrl;
late TextEditingController _dnsCtrl; late TextEditingController _dnsCtrl;
late TextEditingController _mtuCtrl; late TextEditingController _mtuCtrl;
late TextEditingController _domainsCtrl; late TextEditingController _domainsCtrl;
@ -35,60 +31,58 @@ class _SettingsScreenState extends State<SettingsScreen> {
late TextEditingController _dnsDomainCtrl; late TextEditingController _dnsDomainCtrl;
late TextEditingController _pbkCtrl; late TextEditingController _pbkCtrl;
late TextEditingController _sidCtrl; 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; late TextEditingController _muxSessionsCtrl;
bool _isCheckingUpdates = false;
bool _debugMode = false;
String _tunStack = 'ostp';
bool _muxEnabled = false;
bool _tcpFragmentation = false; bool _tcpFragmentation = false;
List<OstpProfile> _profiles = [];
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_importCtrl = TextEditingController();
_loadSettings(); _loadSettings();
} }
void _loadSettings() { void _loadSettings() {
_serverCtrl = TextEditingController(text: widget.prefs.getString('server_addr') ?? '');
_localBindCtrl = TextEditingController(text: widget.prefs.getString('local_bind') ?? '127.0.0.1:1088'); _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') ?? ''); _dnsCtrl = TextEditingController(text: widget.prefs.getString('dns_server') ?? '');
_mtuCtrl = TextEditingController(text: widget.prefs.getString('mtu') ?? '1140'); _mtuCtrl = TextEditingController(text: widget.prefs.getString('mtu') ?? '1140');
_transportMode = widget.prefs.getString('transport_mode') ?? 'udp';
_tcpFragmentation = widget.prefs.getBool('tcp_fragmentation') ?? false; _tcpFragmentation = widget.prefs.getBool('tcp_fragmentation') ?? false;
_domainsCtrl = TextEditingController(text: widget.prefs.getString('ex_domains') ?? ''); _domainsCtrl = TextEditingController(text: widget.prefs.getString('ex_domains') ?? '');
_ipsCtrl = TextEditingController(text: widget.prefs.getString('ex_ips') ?? ''); _ipsCtrl = TextEditingController(text: widget.prefs.getString('ex_ips') ?? '');
_processesCtrl = TextEditingController(text: widget.prefs.getString('ex_processes') ?? ''); _processesCtrl = TextEditingController(text: widget.prefs.getString('ex_processes') ?? '');
_dnsDomainCtrl = TextEditingController(text: widget.prefs.getString('dns_domain') ?? ''); _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') ?? ''); _pbkCtrl = TextEditingController(text: widget.prefs.getString('tun_pbk') ?? '');
_sidCtrl = TextEditingController(text: widget.prefs.getString('sid') ?? ''); _sidCtrl = TextEditingController(text: widget.prefs.getString('sid') ?? '');
_tunStack = widget.prefs.getString('tun_stack') ?? 'ostp'; _tunStack = widget.prefs.getString('tun_stack') ?? 'ostp';
_debugMode = widget.prefs.getBool('debug_mode') ?? false; _debugMode = widget.prefs.getBool('debug_mode') ?? false;
_muxEnabled = widget.prefs.getBool('mux_enabled') ?? false; _muxEnabled = widget.prefs.getBool('mux_enabled') ?? false;
_muxSessionsCtrl = TextEditingController(text: widget.prefs.getString('mux_sessions') ?? '2'); _muxSessionsCtrl = TextEditingController(text: widget.prefs.getString('mux_sessions') ?? '2');
final profilesJson = widget.prefs.getString('profiles_json');
if (profilesJson != null && profilesJson.isNotEmpty) {
try {
final List<dynamic> decoded = jsonDecode(profilesJson);
_profiles = decoded.map((e) => OstpProfile.fromJson(e)).toList();
} catch (e) {
debugPrint('Error loading profiles: $e');
}
}
} }
@override @override
void dispose() { void dispose() {
_saveSettings(); _saveSettings();
_importCtrl.dispose();
_serverCtrl.dispose();
_localBindCtrl.dispose(); _localBindCtrl.dispose();
_keyCtrl.dispose();
_dnsCtrl.dispose(); _dnsCtrl.dispose();
_mtuCtrl.dispose(); _mtuCtrl.dispose();
_domainsCtrl.dispose(); _domainsCtrl.dispose();
_ipsCtrl.dispose(); _ipsCtrl.dispose();
_processesCtrl.dispose(); _processesCtrl.dispose();
_dnsDomainCtrl.dispose(); _dnsDomainCtrl.dispose();
_dnsRegionCtrl.dispose();
_pbkCtrl.dispose(); _pbkCtrl.dispose();
_sidCtrl.dispose(); _sidCtrl.dispose();
_muxSessionsCtrl.dispose(); _muxSessionsCtrl.dispose();
@ -96,12 +90,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
} }
void _saveSettings() { void _saveSettings() {
widget.prefs.setString('server_addr', _serverCtrl.text.trim());
widget.prefs.setString('local_bind', _localBindCtrl.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('dns_server', _dnsCtrl.text.trim());
widget.prefs.setString('mtu', _mtuCtrl.text.trim()); widget.prefs.setString('mtu', _mtuCtrl.text.trim());
widget.prefs.setString('transport_mode', _transportMode);
widget.prefs.setBool('tcp_fragmentation', _tcpFragmentation); widget.prefs.setBool('tcp_fragmentation', _tcpFragmentation);
widget.prefs.setString('ex_domains', _domainsCtrl.text.trim()); widget.prefs.setString('ex_domains', _domainsCtrl.text.trim());
widget.prefs.setString('ex_ips', _ipsCtrl.text.trim()); widget.prefs.setString('ex_ips', _ipsCtrl.text.trim());
@ -109,23 +100,210 @@ class _SettingsScreenState extends State<SettingsScreen> {
widget.prefs.setBool('debug_mode', _debugMode); widget.prefs.setBool('debug_mode', _debugMode);
widget.prefs.setString('tun_stack', _tunStack); widget.prefs.setString('tun_stack', _tunStack);
widget.prefs.setString('dns_domain', _dnsDomainCtrl.text.trim()); 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('tun_pbk', _pbkCtrl.text.trim());
widget.prefs.setString('sid', _sidCtrl.text.trim()); widget.prefs.setString('sid', _sidCtrl.text.trim());
widget.prefs.setBool('mux_enabled', _muxEnabled); widget.prefs.setBool('mux_enabled', _muxEnabled);
widget.prefs.setString('mux_sessions', _muxSessionsCtrl.text.trim()); 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( 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);
},
),
],
),
),
);
}
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<String>(
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, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(label, style: const TextStyle(color: Colors.white54, fontSize: 13, fontWeight: FontWeight.bold, letterSpacing: 1.0)), Text(label, style: const TextStyle(color: Colors.white54, fontSize: 13, fontWeight: FontWeight.bold, letterSpacing: 1.0)),
const SizedBox(height: 10), const SizedBox(height: 10),
TextField( TextField(
controller: controller, controller: controller,
obscureText: isPassword && _obscureKey,
maxLines: maxLines, maxLines: maxLines,
style: TextStyle(fontSize: 16, fontFamily: isMono ? 'monospace' : 'Inter'), style: const TextStyle(fontSize: 16),
decoration: InputDecoration( decoration: InputDecoration(
hintText: hint, hintText: hint,
hintStyle: const TextStyle(color: Colors.white30), hintStyle: const TextStyle(color: Colors.white30),
@ -133,14 +311,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
fillColor: Theme.of(context).colorScheme.surface, fillColor: Theme.of(context).colorScheme.surface,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none), border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), 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,
), ),
), ),
const SizedBox(height: 24),
], ],
),
); );
} }
@ -163,12 +337,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
Switch( Switch(
value: value, value: value,
onChanged: (v) { onChanged: (v) {
onChanged(v); setState(() => onChanged(v));
_saveSettings(); _saveSettings();
}, },
activeColor: Theme.of(context).colorScheme.secondary, 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<SettingsScreen> {
), ),
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(Icons.share_rounded), icon: const Icon(Icons.add_rounded),
tooltip: 'Share Config', tooltip: 'Add Profile',
onPressed: _showShareModal, 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( body: ListView(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
children: [ children: [
// Quick Import Row const Text('PROFILES', style: TextStyle(color: Colors.white54, fontSize: 13, fontWeight: FontWeight.bold, letterSpacing: 1.0)),
Row( const SizedBox(height: 16),
children: [ if (_profiles.isEmpty)
Expanded( Center(
child: TextField( child: Padding(
controller: _importCtrl, padding: const EdgeInsets.all(32.0),
decoration: InputDecoration( child: Text('Create a new profile', style: TextStyle(color: Colors.white.withOpacity(0.5), fontSize: 18)),
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),
), ),
), )
), else
const SizedBox(width: 12), ..._profiles.map((p) => Card(
ElevatedButton( color: Theme.of(context).colorScheme.surface,
onPressed: () { margin: const EdgeInsets.only(bottom: 12),
final raw = _importCtrl.text.trim(); shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
if (raw.isEmpty) return; child: ListTile(
try { leading: Checkbox(
if (!raw.startsWith('ostp://')) { value: p.active,
throw Exception('Link must start with ostp://'); onChanged: (val) {
}
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');
}
setState(() { setState(() {
_serverCtrl.text = host; p.active = val ?? false;
_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();
_saveSettings(); _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( Container(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
@ -282,144 +422,34 @@ class _SettingsScreenState extends State<SettingsScreen> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ 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('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('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)'), _buildTextField('MTU (Packet Size)', _mtuCtrl, hint: '1140 (decrease if connection drops)'),
// Transport Mode const SizedBox(height: 24),
const Text('Transport Mode', style: TextStyle(color: Colors.white54, fontSize: 13, fontWeight: FontWeight.bold, letterSpacing: 1.0)), SizedBox(
const SizedBox(height: 10), width: double.infinity,
Container( child: ElevatedButton.icon(
decoration: BoxDecoration( icon: const Icon(Icons.route),
color: Theme.of(context).colorScheme.surface, label: const Text('Configure Split Tunneling'),
borderRadius: BorderRadius.circular(12), onPressed: () {
Navigator.push(context, MaterialPageRoute(builder: (context) => AppRoutingScreen(prefs: widget.prefs)));
},
), ),
child: Column(
children: [
RadioListTile<String>(
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<String>(
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<String>(
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: 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), const SizedBox(height: 16),
_buildTextField('Domain (Points to Server)', _dnsDomainCtrl, hint: 'tunnel.myvpn.com'), SizedBox(
const SizedBox(height: 16), width: double.infinity,
Row( child: ElevatedButton.icon(
children: [ icon: const Icon(Icons.article),
Expanded( label: const Text('View Logs'),
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: () { onPressed: () {
Navigator.push(context, MaterialPageRoute(builder: (context) => const LogsScreen())); Navigator.push(context, MaterialPageRoute(builder: (context) => const LogsScreen()));
}, },
@ -427,344 +457,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
], ],
), ),
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)),
],
),
),
_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 = <String>[];
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<void> _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<String?>(
future: channel.invokeMethod<String>('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<dynamic> 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<void> _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; });
}
}
}

View File

@ -156,23 +156,22 @@
<!-- Form card --> <!-- Form card -->
<div class="card scrollable"> <div class="card scrollable">
<div class="field-group">
<label class="field-label" for="in-server" data-i18n="label_server">Server Address</label>
<input id="in-server" class="field-input" type="text" placeholder="host:port" spellcheck="false" />
</div>
<div class="field-group"> <!-- Profiles Section -->
<label class="field-label" for="in-key" data-i18n="label_key">Access Key</label> <div class="profiles-section">
<div class="input-wrap"> <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom: 12px;">
<input id="in-key" class="field-input has-icon" type="password" data-i18n-placeholder="ph_key" placeholder="Secure access key" spellcheck="false" /> <span class="field-label">PROFILES</span>
<button class="peek-btn" id="btn-peek-key" tabindex="-1" aria-label="Show key"> <button id="btn-add-profile" class="icon-btn" style="width:24px; height:24px;">+</button>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> </div>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/> <div id="profiles-list" class="profiles-list">
<circle cx="12" cy="12" r="3"/> <!-- Profiles will be injected here -->
</svg> </div>
</button> <div id="profiles-empty" class="profiles-empty" style="display:none; text-align:center; padding: 20px; color: var(--c-text-muted);">
Create a new profile
</div> </div>
</div> </div>
<hr style="border:0; border-top: 1px solid var(--c-surface); margin: 24px 0;" />
<div class="field-label" style="margin-bottom: 16px;">CLIENT SETTINGS</div>
<div class="field-group"> <div class="field-group">
<label class="field-label" for="in-socks" data-i18n="label_socks">Local Proxy</label> <label class="field-label" for="in-socks" data-i18n="label_socks">Local Proxy</label>
@ -184,14 +183,8 @@
<input id="in-dns" class="field-input" type="text" placeholder="1.1.1.1" /> <input id="in-dns" class="field-input" type="text" placeholder="1.1.1.1" />
</div> </div>
<div class="field-group"> <!-- Transport moved to profile modal -->
<label class="field-label" for="in-transport" data-i18n="label_transport">Transport Protocol</label>
<select id="in-transport" class="field-input">
<option value="udp" data-i18n="opt_udp">UDP (Default)</option>
<option value="uot" data-i18n="opt_uot">TCP (UoT)</option>
<option value="dns" data-i18n="opt_dns">DNS Proxy (Last Resort)</option>
</select>
</div>
<div id="group-dns-proxy" style="display: none; flex-direction: column; gap: 14px;"> <div id="group-dns-proxy" style="display: none; flex-direction: column; gap: 14px;">
<div class="field-group"> <div class="field-group">
@ -387,5 +380,39 @@
</div> </div>
<script type="module" src="main.js"></script> <script type="module" src="main.js"></script>
<!-- Profile Modal -->
<div id="profile-modal" class="modal hidden">
<div class="modal-content">
<h3 id="profile-modal-title">Edit Profile</h3>
<div class="field-group">
<label>Name</label>
<input id="in-prof-name" class="field-input" type="text" />
</div>
<div class="field-group">
<label>Server (host:port)</label>
<input id="in-prof-server" class="field-input" type="text" />
</div>
<div class="field-group">
<label>Access Key</label>
<input id="in-prof-key" class="field-input" type="password" />
</div>
<div class="field-group">
<label>Transport</label>
<select id="in-prof-transport" class="field-input">
<option value="udp">UDP</option>
<option value="uot">TCP (UoT)</option>
</select>
</div>
<div style="display:flex; justify-content:space-between; margin-top: 20px;">
<button id="btn-prof-delete" class="danger-btn">Delete</button>
<div>
<button id="btn-prof-cancel" class="cancel-btn">Cancel</button>
<button id="btn-prof-save" class="accent-btn">Save</button>
</div>
</div>
</div>
</div>
</body> </body>
</html> </html>

View File

@ -38,7 +38,8 @@ let appState = 'disconnected'; // 'disconnected' | 'connecting' | 'connected'
let pollTimer = null; let pollTimer = null;
let uptimeTimer = null; let uptimeTimer = null;
let uptimeSecs = 0; let uptimeSecs = 0;
let rawConfig = null; // parsed config.json object let rawConfig = null;
let profiles = []; // parsed config.json object
let serverAddr = ''; // current server address (for badge) let serverAddr = ''; // current server address (for badge)
// ── DOM refs ───────────────────────────────────────────────────────────────── // ── DOM refs ─────────────────────────────────────────────────────────────────
@ -61,6 +62,20 @@ const toast = $('toast');
const btnGoSettings = $('btn-go-settings'); const btnGoSettings = $('btn-go-settings');
const btnAutoConnect = $('btn-auto-connect'); const btnAutoConnect = $('btn-auto-connect');
const btnAddProfile = $('btn-add-profile');
const profilesList = $('profiles-list');
const profilesEmpty = $('profiles-empty');
const profileModal = $('profile-modal');
const inProfName = $('in-prof-name');
const inProfServer = $('in-prof-server');
const inProfKey = $('in-prof-key');
const inProfTransport = $('in-prof-transport');
const btnProfDelete = $('btn-prof-delete');
const btnProfCancel = $('btn-prof-cancel');
const btnProfSave = $('btn-prof-save');
let editingProfileId = null;
const btnBack = $('btn-back'); const btnBack = $('btn-back');
const btnImport = $('btn-import-url'); const btnImport = $('btn-import-url');
const btnPeekKey = $('btn-peek-key'); const btnPeekKey = $('btn-peek-key');
@ -86,8 +101,8 @@ const inLaunchStartup = $('in-launch-startup');
function bindSettingsInputs() { function bindSettingsInputs() {
const ids = [ const ids = [
'in-server', 'in-key', 'in-socks', 'in-dns', 'in-socks', 'in-dns',
'in-transport', 'in-dns-domain', 'in-dns-region', 'in-dns-domain', 'in-dns-region',
'in-mtu', 'in-mux-sessions', 'in-mtu', 'in-mux-sessions',
'in-tun-mode', 'in-kill-switch', 'in-mux-mode', 'in-tun-mode', 'in-kill-switch', 'in-mux-mode',
'in-debug', 'in-autoconnect', 'in-launch-startup' 'in-debug', 'in-autoconnect', 'in-launch-startup'
@ -911,3 +926,90 @@ window.addEventListener('DOMContentLoaded', async () => {
}); });
} }
}); });
function renderProfiles() {
if (profiles.length === 0) {
profilesList.innerHTML = '';
profilesEmpty.style.display = 'block';
} else {
profilesEmpty.style.display = 'none';
profilesList.innerHTML = profiles.map(p => `
<div class="profile-item">
<input type="checkbox" ${p.active ? 'checked' : ''} onchange="toggleProfile('${p.id}')">
<div class="profile-info">
<div class="profile-name">${p.name}</div>
<div class="profile-addr">${p.serverAddr}</div>
</div>
<button class="icon-btn" onclick="editProfile('${p.id}')" style="width:24px;height:24px;"></button>
</div>
`).join('');
}
}
window.toggleProfile = function(id) {
const p = profiles.find(x => x.id === id);
if (p) { p.active = !p.active; saveSettings(); renderProfiles(); }
};
window.editProfile = function(id) {
editingProfileId = id;
const p = profiles.find(x => x.id === id);
$('profile-modal-title').innerText = 'Edit Profile';
inProfName.value = p.name;
inProfServer.value = p.serverAddr;
inProfKey.value = p.accessKey;
inProfTransport.value = p.transportMode || 'udp';
btnProfDelete.style.display = 'block';
profileModal.classList.remove('hidden');
};
if (btnAddProfile) {
btnAddProfile.addEventListener('click', () => {
editingProfileId = null;
$('profile-modal-title').innerText = 'New Profile';
inProfName.value = '';
inProfServer.value = '';
inProfKey.value = '';
inProfTransport.value = 'udp';
btnProfDelete.style.display = 'none';
profileModal.classList.remove('hidden');
});
}
if (btnProfCancel) btnProfCancel.addEventListener('click', () => profileModal.classList.add('hidden'));
if (btnProfDelete) {
btnProfDelete.addEventListener('click', () => {
profiles = profiles.filter(x => x.id !== editingProfileId);
profileModal.classList.add('hidden');
saveSettings();
renderProfiles();
});
}
if (btnProfSave) {
btnProfSave.addEventListener('click', () => {
if (editingProfileId) {
const p = profiles.find(x => x.id === editingProfileId);
if (p) {
p.name = inProfName.value.trim();
p.serverAddr = inProfServer.value.trim();
p.accessKey = inProfKey.value;
p.transportMode = inProfTransport.value;
}
} else {
profiles.push({
id: Date.now().toString(),
name: inProfName.value.trim() || 'New Profile',
serverAddr: inProfServer.value.trim(),
accessKey: inProfKey.value,
transportMode: inProfTransport.value,
active: true
});
}
profileModal.classList.add('hidden');
saveSettings();
renderProfiles();
});
}

View File

@ -1218,3 +1218,14 @@ html[data-theme="light"] .proc-item:hover { background: rgba(0,0,0,0.05); }
border-top: 1px solid var(--c-card-border); border-top: 1px solid var(--c-card-border);
padding: 12px 20px; padding: 12px 20px;
} }
.profiles-list { display: flex; flex-direction: column; gap: 8px; }
.profile-item { display: flex; align-items: center; background: var(--c-surface); padding: 12px; border-radius: 12px; }
.profile-info { flex-grow: 1; margin-left: 12px; }
.profile-name { font-weight: bold; font-size: 14px; }
.profile-addr { font-size: 11px; color: var(--c-text-muted); }
.modal { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; }
.modal.hidden { display: none; }
.modal-content { background: var(--c-bg); padding: 24px; border-radius: 16px; width: 320px; max-width: 90%; }
.danger-btn { background: rgba(255,50,50,0.2); color: #ff5555; padding: 8px 16px; border-radius: 8px; font-weight: bold; }
.cancel-btn { background: transparent; color: var(--c-text); padding: 8px 16px; margin-right: 8px; }

View File

@ -23,6 +23,7 @@ colored = "2.1"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] } reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }
pico-args = "0.5.0" pico-args = "0.5.0"
clipboard-win = "3.1.1" clipboard-win = "3.1.1"
rlimit = "0.11.0"
[target."cfg(windows)".build-dependencies] [target."cfg(windows)".build-dependencies]
winres = "0.1.12" winres = "0.1.12"

View File

@ -463,6 +463,7 @@ struct FallbackCfg {
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
let _ = rlimit::increase_nofile_limit(1048576);
ostp_client::logging::setup_panic_hook(); ostp_client::logging::setup_panic_hook();
let _log_guard = ostp_client::logging::init_tracing("info", "ostp-cli", env!("CARGO_PKG_VERSION")); let _log_guard = ostp_client::logging::init_tracing("info", "ostp-cli", env!("CARGO_PKG_VERSION"));