ios IP Layer Proxy, Support capturing Flutter applications (#215)

This commit is contained in:
wanghongenpin
2024-10-10 18:58:47 +08:00
parent 86d342cb07
commit 05667189a8
16 changed files with 174 additions and 149 deletions

View File

@@ -4,7 +4,7 @@
## 开源免费抓包工具支持Windows、Mac、Android、IOS、Linux 全平台系统
您可以使用它来拦截、检查和重写HTTPS流量ProxyPin基于Flutter开发UI美观易用。
您可以使用它来拦截、检查和重写HTTPS流量支持Flutter应用抓包ProxyPin基于Flutter开发UI美观易用。
## 核心特性
@@ -20,9 +20,7 @@
国内下载地址: https://gitee.com/wanghongenpin/network-proxy-flutter/releases
AppStore下载地址 https://apps.apple.com/app/proxypin/id6450932949
iOS国内TF下载地址(有1万名额限制) https://testflight.apple.com/join/gURGH6B4
iOS AppStore下载地址 https://apps.apple.com/app/proxypin/id6450932949
TG: https://t.me/proxypin_tg

View File

@@ -2,7 +2,7 @@
English | [中文](README.md)
## Open source free packet capture toolSupport Windows、Mac、Android、IOS、Linux Full platform system
You can use it to intercept, inspect & rewrite HTTP(S) traffic, ProxyPin is based on Flutter develop, and the UI is beautiful
You can use it to intercept, inspect & rewrite HTTP(S) traffic, Support capturing Flutter app traffic, ProxyPin is based on Flutter develop, and the UI is beautiful
and easy to use.
## Features
* Mobile scan code connection: no need to manually configure WiFi proxy, including configuration synchronization. All terminals can scan codes to connect and forward traffic to each other.

View File

@@ -24,7 +24,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
let proxyPort = conf["proxyPort"] as! Int
let ipProxy = conf["ipProxy"] as! Bool? ?? false
let networkSettings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "127.0.0.1")
let networkSettings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: host)
// let networkSettings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: host)
NSLog(conf.debugDescription)
//http
@@ -39,7 +39,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
networkSettings.mtu = 1480
let ipv4Settings = NEIPv4Settings(addresses: ["10.0.0.2"], subnetMasks: ["255.255.255.255"])
if (ipProxy){
ipv4Settings.includedRoutes = [NEIPv4Route.default()]
// ipv4Settings.excludedRoutes = [

View File

@@ -37,7 +37,7 @@ class Connection{
self.connectionCloser = connectionCloser
}
//vpn
//vpn
var sendBuffer = Data()
var hasReceivedLastSegment = false
@@ -73,30 +73,35 @@ class Connection{
}
func addSendData(data: Data) {
QueueFactory.instance.getQueue().sync {
// sendBuffer.append(data)
// if (channel?.state != .ready) {
// return
// }
self.sendToDestination(data: data)
QueueFactory.instance.getQueue().async(flags: .barrier) {
self.sendBuffer.append(data)
if (self.nwProtocol == .TCP && self.channel?.state != .ready) {
return
}
self.sendToDestination()
}
}
//
func sendToDestination(data: Data) {
// QueueFactory.instance.getQueue().sync {
// os_log("Sending data to destination key %{public}@", log: OSLog.default, type: .debug, self.description)
//,
self.channel?.send(content: data, completion: .contentProcessed { error in
func sendToDestination() {
QueueFactory.instance.getQueue().async(flags: .barrier) {
os_log("Sending data to destination key %{public}@", log: OSLog.default, type: .debug, self.description)
if (self.sendBuffer.count == 0) {
return
}
let data = self.sendBuffer
self.sendBuffer.removeAll()
self.channel?.send(content: data, completion: .contentProcessed({ error in
if let error = error {
os_log("Error sending data to destination %{public}@: %{public}@", log: OSLog.default, type: .error, self.description, error.localizedDescription)
return
os_log("Failed to send data to destination key %{public}@ error: %{public}@", log: OSLog.default, type: .error, self.description, error.localizedDescription)
self.closeConnection()
}
})
// }
}))
}
}
var description: String {

View File

@@ -127,7 +127,7 @@ class ConnectionHandler {
// os_log("Handling TCP packet for %{public}@ flags:%d", log: OSLog.default, type: .default, Connection.getConnectionKey(nwProtocol: .TCP, destIp: destinationIP, destPort: destinationPort, sourceIp: sourceIP, sourcePort: sourcePort), tcpHeader.flags)
if (tcpHeader.isSYN()) {
os_log("Received SYN packet %{public}@ seq:%u", log: OSLog.default, type: .default, Connection.getConnectionKey(nwProtocol: .TCP, destIp: destinationIP, destPort: destinationPort, sourceIp: sourceIP, sourcePort: sourcePort), tcpHeader.sequenceNumber)
// os_log("Received SYN packet %{public}@ seq:%u", log: OSLog.default, type: .default, Connection.getConnectionKey(nwProtocol: .TCP, destIp: destinationIP, destPort: destinationPort, sourceIp: sourceIP, sourcePort: sourcePort), tcpHeader.sequenceNumber)
// 3-way handshake + create new session
replySynAck(ipHeader: ipHeader, tcpHeader: tcpHeader)
} else if (tcpHeader.isACK()) {

View File

@@ -18,9 +18,7 @@ class ConnectionManager : CloseableConnection{
public var proxyAddress: NWEndpoint?
private let defaultPorts: [UInt16] = [80, 443]
// private init() {}
private let defaultPorts: [UInt16] = [80, 8080, 8888, 443]
func getConnection(nwProtocol: NWProtocol, ip: UInt32, port: UInt16, srcIp: UInt32, srcPort: UInt16) -> Connection? {

View File

@@ -10,7 +10,7 @@ import NetworkExtension
import os.log
class SocketIOService {
//private static let maxReceiveBufferSize = 16384
// private static let maxReceiveBufferSize = 16384
private static let maxReceiveBufferSize = 1024
private let queue: DispatchQueue = DispatchQueue(label: "ProxyPin.SocketIOService", attributes: .concurrent)
@@ -40,7 +40,7 @@ class SocketIOService {
connection.isConnected = true
os_log("Connected to %{public}@ on receiveMessage", log: OSLog.default, type: .default, connection.description)
//
// connection.sendToDestination()
connection.sendToDestination()
self.receiveMessage(connection: connection)
case .cancelled:
connection.isConnected = false
@@ -90,8 +90,8 @@ class SocketIOService {
return
}
channel.receive(minimumIncompleteLength: 1, maximumLength: Self.maxReceiveBufferSize) { (data, context, isComplete, error) in
// os_log("Received TCP data packet %{public}@ length %d", log: OSLog.default, type: .default, connection.description, data?.count ?? 0)
channel.receive(minimumIncompleteLength: 0, maximumLength: Self.maxReceiveBufferSize) { (data, context, isComplete, error) in
// os_log("Received TCP data packet %{public}@ length %d", log: OSLog.default, type: .default, connection.description, data?.count ?? 0)
if let error = error {
os_log("Failed to read from TCP socket: %@", log: OSLog.default, type: .error, error as CVarArg)
self.sendFin(connection: connection)

View File

@@ -20,8 +20,8 @@ import NetworkExtension
result(Bool(VpnManager.shared.isRunning()))
} else if ("restartVpn" == call.method){
let arguments = call.arguments as? Dictionary<String, AnyObject>
// VpnManager.shared.disconnect()
VpnManager.shared.connect(host: arguments?["proxyHost"] as? String ,port: arguments?["proxyPort"] as? Int, ipProxy: arguments?["ipProxy"] as? Bool)
// VpnManager.shared.disconnect()
VpnManager.shared.restartConnect(host: arguments?["proxyHost"] as? String ,port: arguments?["proxyPort"] as? Int, ipProxy: arguments?["ipProxy"] as? Bool)
} else {
let arguments = call.arguments as? Dictionary<String, AnyObject>
VpnManager.shared.connect(host: arguments?["proxyHost"] as? String ,port: arguments?["proxyPort"] as? Int, ipProxy: arguments?["ipProxy"] as? Bool)

View File

@@ -156,6 +156,19 @@ extension VpnManager{
}
}
}
func restartConnect(host: String?, port: Int?, ipProxy: Bool? = false) {
self.proxyHost = host ?? self.proxyHost
self.proxyPort = port ?? self.proxyPort
self.ipProxy = ipProxy ?? false
if (activeVPN != nil) {
activeVPN?.connection.stopVPNTunnel()
activeVPN = nil
}
self.connect(host: host, port: port, ipProxy: ipProxy)
}
func disconnect() {
if (activeVPN != nil) {

View File

@@ -237,6 +237,7 @@
"notConnected": "Not connected",
"disconnect": "Disconnect",
"ipLayerProxy": "IP Layer Proxy(Beta)",
"ipLayerProxyDesc": "IP layer proxy can capture Flutter app requests, currently not very stable",
"inputAddress": "Input Address",
"syncConfig": "Sync configuration",
"pullConfigFail": "Failed to pull configuration, please check the network connection",

View File

@@ -238,6 +238,7 @@
"inputAddress": "输入地址",
"disconnect": "断开连接",
"ipLayerProxy": "IP层代理(Beta)",
"ipLayerProxyDesc": "IP层代理可抓取Flutter应用请求目前不是很稳定",
"syncConfig": "同步配置",
"pullConfigFail": "拉取配置失败, 请检查网络连接",
"sync": "同步",

View File

@@ -17,95 +17,111 @@
import 'dart:typed_data';
///类似于netty ByteBuf
class ByteBuf {
final BytesBuilder _buffer = BytesBuilder();
late Uint8List _buffer;
int readerIndex = 0;
Uint8List get bytes => _buffer.toBytes();
int get length => _buffer.length;
int writerIndex = 0;
ByteBuf([List<int>? bytes]) {
if (bytes != null) _buffer.add(bytes);
if (bytes != null) {
_buffer = Uint8List.fromList(bytes);
writerIndex = bytes.length;
} else {
_buffer = Uint8List(256); // Initial buffer size
}
}
///添加
int get length => writerIndex;
Uint8List get bytes => Uint8List.sublistView(_buffer, 0, writerIndex);
void add(List<int> bytes) {
_buffer.add(bytes);
_ensureCapacity(writerIndex + bytes.length);
_buffer.setRange(writerIndex, writerIndex + bytes.length, bytes);
writerIndex += bytes.length;
}
///清空
clear() {
_buffer.clear();
void clear() {
readerIndex = 0;
writerIndex = 0;
}
///释放
clearRead() {
var takeBytes = _buffer.takeBytes();
_buffer.add(Uint8List.sublistView(takeBytes, readerIndex, takeBytes.length));
readerIndex = 0;
void clearRead() {
if (readerIndex > 0) {
_buffer.setRange(0, writerIndex - readerIndex, _buffer, readerIndex);
writerIndex -= readerIndex;
readerIndex = 0;
}
}
bool isReadable() => readerIndex < _buffer.length;
bool isReadable() => readerIndex < writerIndex;
///可读字节数
int readableBytes() {
return _buffer.length - readerIndex;
}
int readableBytes() => writerIndex - readerIndex;
///读取所有可用字节
Uint8List readAvailableBytes() {
return readBytes(readableBytes());
}
Uint8List readAvailableBytes() => readBytes(readableBytes());
///读取字节
Uint8List readBytes(int length) {
Uint8List list = bytes.sublist(readerIndex, readerIndex + length);
if (readerIndex + length > writerIndex) {
throw Exception("Not enough readable bytes");
}
Uint8List result = Uint8List.sublistView(_buffer, readerIndex, readerIndex + length);
readerIndex += length;
return list;
return result;
}
///跳过
skipBytes(int length) {
void skipBytes(int length) {
if (readerIndex + length > writerIndex) {
throw Exception("Not enough readable bytes");
}
readerIndex += length;
}
///读取字节
int read() {
return bytes[readerIndex++];
}
int read() => _buffer[readerIndex++];
///读取字节
int readByte() {
return bytes[readerIndex++];
}
int readByte() => _buffer[readerIndex++];
int readShort() {
int value = bytes[readerIndex++] << 8 | bytes[readerIndex++];
int value = (_buffer[readerIndex] << 8) | _buffer[readerIndex + 1];
readerIndex += 2;
return value;
}
int readInt() {
int value =
bytes[readerIndex++] << 24 | bytes[readerIndex++] << 16 | bytes[readerIndex++] << 8 | bytes[readerIndex++];
int value = (_buffer[readerIndex] << 24) |
(_buffer[readerIndex + 1] << 16) |
(_buffer[readerIndex + 2] << 8) |
_buffer[readerIndex + 3];
readerIndex += 4;
return value;
}
int get(int index) {
return bytes[index];
}
int get(int index) => _buffer[index];
void truncate(int len) {
if (len > readableBytes()) throw Exception("insufficient data");
var takeBytes = _buffer.takeBytes();
_buffer.add(takeBytes.sublist(0, readerIndex + len));
if (len > readableBytes()) {
throw Exception("Insufficient data");
}
writerIndex = readerIndex + len;
}
ByteBuf dup() {
ByteBuf buf = ByteBuf();
buf.add(bytes);
buf._buffer = Uint8List.fromList(_buffer);
buf.readerIndex = readerIndex;
buf.writerIndex = writerIndex;
return buf;
}
}
void _ensureCapacity(int required) {
if (_buffer.length < required) {
int newSize = _buffer.length * 2;
while (newSize < required) {
newSize *= 2;
}
Uint8List newBuffer = Uint8List(newSize);
newBuffer.setRange(0, writerIndex, _buffer);
_buffer = newBuffer;
}
}
}

View File

@@ -60,7 +60,7 @@ class AppConfiguration {
Locale? _language;
//是否显示更新内容公告
bool upgradeNoticeV13 = true;
bool upgradeNoticeV14 = true;
/// 是否启用画中画
ValueNotifier<bool> pipEnabled = ValueNotifier(true);
@@ -174,7 +174,7 @@ class AppConfiguration {
_theme = ThemeModel(mode: mode, useMaterial3: config['useMaterial3'] ?? true);
_theme.color = config['themeColor'] ?? "Blue";
upgradeNoticeV13 = config['upgradeNoticeV13'] ?? true;
upgradeNoticeV14 = config['upgradeNoticeV14'] ?? true;
_language = config['language'] == null ? null : Locale.fromSubtags(languageCode: config['language']);
pipEnabled.value = config['pipEnabled'] ?? true;
pipIcon.value = config['pipIcon'] ?? false;
@@ -218,7 +218,7 @@ class AppConfiguration {
'mode': _theme.mode.name,
'themeColor': _theme.color,
'useMaterial3': _theme.useMaterial3,
'upgradeNoticeV13': upgradeNoticeV13,
'upgradeNoticeV14': upgradeNoticeV14,
"language": _language?.languageCode,
"headerExpanded": headerExpanded,

View File

@@ -82,7 +82,7 @@ class _DesktopHomePagePageState extends State<DesktopHomePage> implements EventL
proxyServer.addListener(this);
panel = NetworkTabController(tabStyle: const TextStyle(fontSize: 16), proxyServer: proxyServer);
if (widget.appConfiguration.upgradeNoticeV13) {
if (widget.appConfiguration.upgradeNoticeV14) {
WidgetsBinding.instance.addPostFrameCallback((_) {
showUpgradeNotice();
});
@@ -136,7 +136,7 @@ class _DesktopHomePagePageState extends State<DesktopHomePage> implements EventL
actions: [
TextButton(
onPressed: () {
widget.appConfiguration.upgradeNoticeV13 = false;
widget.appConfiguration.upgradeNoticeV14 = false;
widget.appConfiguration.flushConfig();
Navigator.pop(context);
},
@@ -149,10 +149,10 @@ class _DesktopHomePagePageState extends State<DesktopHomePage> implements EventL
isCN
? '提示默认不会开启HTTPS抓包请安装证书后再开启HTTPS抓包。\n'
'点击HTTPS抓包(加锁图标),选择安装根证书,按照提示操作即可。\n\n'
'1. 支持多种主题颜色选择\n'
'2. 外部代理支持身份验证\n'
'3. 双击列表tab滚动到顶部\n'
'4. 修复部分p12证书导入失败的问题\n'
'1. 手机端增加底部导航,可在设置中切换\n'
'2. 增加远程设备管理,可快速连接设备\n'
'3. iOS支持抓取Flutter应用需要通过设备管理连接到电脑开启IP层代理(Beta)\n'
'4. 工具箱支持Unicode编码\n'
'5. 修复Transfer-Encoding有空格解析错误问题\n'
'6. 脚本增加rawBody原始字节参数, body支持字节数组修改\n'
'7. 修复脚本消息体编码错误导致错误响应;\n'
@@ -160,10 +160,10 @@ class _DesktopHomePagePageState extends State<DesktopHomePage> implements EventL
'9. 修复Websocket Response不展示\n'
: 'TipsBy default, HTTPS packet capture will not be enabled. Please install the certificate before enabling HTTPS packet capture。\n'
'Click HTTPS Capture packets(Lock icon)Choose to install the root certificate and follow the prompts to proceed。\n\n'
'1. Support multiple theme colors\n'
'2. External proxy support authentication\n'
'3. Double-click the list tab to scroll to the top\n'
'4. Fix the issue of partial p12 certificate import failure\n'
'1. Mobile: Add bottom navigation barwhich can be switched in settings\n'
'2. Support remote device management to quickly connect to devices\n'
'3. IOS supports capturing Flutter applications, You need to connect to the computer through device management to enable IP layer proxy (Beta)\n'
'4. Toolbox supports Unicode encode\n'
'5. Fix header Transfer-Encoding with spaces\n'
'6. The script add rawBody raw byte parameter, body supports byte array modification\n'
'7. Fix script message body encoding error causing incorrect response\n'

View File

@@ -109,7 +109,7 @@ class MobileHomeState extends State<MobileHomePage> implements EventListener, Li
proxyServer.addListener(this);
proxyServer.start();
if (widget.appConfiguration.upgradeNoticeV13) {
if (widget.appConfiguration.upgradeNoticeV14) {
WidgetsBinding.instance.addPostFrameCallback((_) {
showUpgradeNotice();
});
@@ -250,8 +250,8 @@ class MobileHomeState extends State<MobileHomePage> implements EventListener, Li
? '提示默认不会开启HTTPS抓包请安装证书后再开启HTTPS抓包。\n\n'
'1. 手机端增加底部导航,可在设置中切换;\n'
'2. 增加远程设备管理,可快速连接设备;\n'
'3. 双击列表tab滚动到顶部\n'
'4. 修复部分p12证书导入失败的问题\n'
'3. iOS支持抓取Flutter应用需要通过设备管理连接到电脑开启IP层代理(Beta)\n'
'4. 工具箱支持Unicode编码\n'
'5. 脚本增加rawBody原始字节参数, body支持字节数组修改\n'
'6. 修复脚本消息体编码错误导致错误响应;\n'
'7. 修复扫码链接多个IP优先级问题\n'
@@ -262,8 +262,8 @@ class MobileHomeState extends State<MobileHomePage> implements EventListener, Li
'Click HTTPS Capture packets(Lock icon)Choose to install the root certificate and follow the prompts to proceed。\n\n'
'1. Mobile: Add bottom navigation barwhich can be switched in settings\n'
'2. Support remote device management to quickly connect to devices\n'
'3. Double-click the list tab to scroll to the top\n'
'4. Fix the issue of partial p12 certificate import failure\n'
'3. IOS supports capturing Flutter applications, You need to connect to the computer through device management to enable IP layer proxy (Beta)\n'
'4. Toolbox supports Unicode encode\n'
'5. The script add rawBody raw byte parameter, body supports byte array modification\n'
'6. Fix script message body encoding error causing incorrect response\n'
'7. Fix the issue of scanning QR code to connect to multiple IP priorities\n'
@@ -271,8 +271,8 @@ class MobileHomeState extends State<MobileHomePage> implements EventListener, Li
'9. Fix export HAR serverIPAddress incorrect\n'
'10. Fix Websocket Response not displayed\n'
'';
showAlertDialog(isCN ? '更新内容V1.1.3' : "Update content V1.1.3", content, () {
widget.appConfiguration.upgradeNoticeV13 = false;
showAlertDialog(isCN ? '更新内容V1.1.4' : "Update content V1.1.4", content, () {
widget.appConfiguration.upgradeNoticeV14 = false;
widget.appConfiguration.flushConfig();
});
}

View File

@@ -60,13 +60,7 @@ class RemoteModel {
factory RemoteModel.fromJson(Map<String, dynamic> json) {
return RemoteModel(
connect: json['connect'],
host: json['host'],
port: json['port'],
os: json['os'],
hostname: json['hostname'],
ipProxy: json['ipProxy'],
);
connect: json['connect'], host: json['host'], port: json['port'], os: json['os'], hostname: json['hostname']);
}
RemoteModel copyWith({
@@ -95,14 +89,7 @@ class RemoteModel {
}
Map<String, dynamic> toJson() {
return {
'connect': connect,
'host': host,
'port': port,
'os': os,
'hostname': hostname,
'ipProxy': ipProxy,
};
return {'connect': connect, 'host': host, 'port': port, 'os': os, 'hostname': hostname};
}
}
@@ -243,32 +230,38 @@ class _RemoteDevicePageState extends State<RemoteDevicePage> {
child: Column(
children: [
const Icon(Icons.check_circle_outline_outlined, size: 55, color: Colors.green),
Row(
children: [
if (Platform.isIOS) Expanded(child: ListTile(title: Text(localizations.ipLayerProxy))),
const SizedBox(height: 6),
SwitchWidget(
value: widget.remoteDevice.value.ipProxy ?? false,
scale: 0.85,
onChanged: (val) async {
widget.remoteDevice.value = widget.remoteDevice.value.copyWith(ipProxy: val);
SharedPreferences.getInstance().then((prefs) {
var remoteDeviceList = getRemoteDeviceList(prefs);
remoteDeviceList.removeWhere((it) => it.equals(widget.remoteDevice.value));
remoteDeviceList.insert(0, widget.remoteDevice.value);
const SizedBox(height: 6),
if (Platform.isIOS)
Row(
children: [
Expanded(
child: ListTile(
title: Text(localizations.ipLayerProxy), subtitle: Text(localizations.ipLayerProxyDesc))),
SwitchWidget(
value: widget.remoteDevice.value.ipProxy ?? false,
scale: 0.85,
onChanged: (val) async {
widget.remoteDevice.value = widget.remoteDevice.value.copyWith(ipProxy: val);
SharedPreferences.getInstance().then((prefs) {
var remoteDeviceList = getRemoteDeviceList(prefs);
remoteDeviceList.removeWhere((it) => it.equals(widget.remoteDevice.value));
remoteDeviceList.insert(0, widget.remoteDevice.value);
setRemoteDeviceList(prefs, remoteDeviceList);
});
setRemoteDeviceList(prefs, remoteDeviceList);
});
if ((await Vpn.isRunning())) {
print('重启VPN');
Vpn.restartVpn(widget.remoteDevice.value.host!, widget.remoteDevice.value.port!,
widget.proxyServer.configuration,
ipProxy: val);
}
}),
],
),
if ((await Vpn.isRunning())) {
Vpn.stopVpn();
Future.delayed(const Duration(milliseconds: 1500), () {
Vpn.startVpn(widget.remoteDevice.value.host!, widget.remoteDevice.value.port!,
widget.proxyServer.configuration,
ipProxy: val);
});
}
}),
],
),
const SizedBox(height: 6),
Text('${localizations.connected}${widget.remoteDevice.value.hostname}',
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500)),
const SizedBox(height: 6),
@@ -524,7 +517,7 @@ class ConfigSyncState extends State<ConfigSyncWidget> {
title: Text(localizations.syncConfig, style: const TextStyle(fontSize: 16)),
content: Wrap(children: [
SwitchWidget(
title: "${localizations.sync}${localizations.domainWhitelist}",
title: "${localizations.sync} ${localizations.domainWhitelist}",
value: syncWhiteList,
onChanged: (val) {
setState(() {
@@ -533,7 +526,7 @@ class ConfigSyncState extends State<ConfigSyncWidget> {
}),
const SizedBox(height: 5),
SwitchWidget(
title: "${localizations.sync}${localizations.domainBlacklist}",
title: "${localizations.sync} ${localizations.domainBlacklist}",
value: syncBlackList,
onChanged: (val) {
setState(() {
@@ -542,7 +535,7 @@ class ConfigSyncState extends State<ConfigSyncWidget> {
}),
const SizedBox(height: 5),
SwitchWidget(
title: "${localizations.sync}${localizations.requestRewrite}",
title: "${localizations.sync} ${localizations.requestRewrite}",
value: syncRewrite,
onChanged: (val) {
setState(() {
@@ -551,7 +544,7 @@ class ConfigSyncState extends State<ConfigSyncWidget> {
}),
const SizedBox(height: 5),
SwitchWidget(
title: "${localizations.sync}${localizations.script}",
title: "${localizations.sync} ${localizations.script}",
value: syncScript,
onChanged: (val) {
setState(() {
@@ -566,7 +559,7 @@ class ConfigSyncState extends State<ConfigSyncWidget> {
Navigator.pop(context);
}),
TextButton(
child: Text('${localizations.start}${localizations.sync}'),
child: Text('${localizations.start} ${localizations.sync}'),
onPressed: () async {
if (syncWhiteList) {
HostFilter.whitelist.load(widget.config['whitelist']);