Support remote device management

This commit is contained in:
wanghongenpin
2024-10-06 17:28:15 +08:00
parent 201166e466
commit aded2b7f37
9 changed files with 520 additions and 383 deletions

View File

@@ -7,7 +7,7 @@
"preference": "Preferences",
"feedback": "Feedback",
"about": "About",
"filter": "Filter",
"filter": "Proxy Filter",
"script": "Script",
"share": "Share",
"port": "Port: ",
@@ -26,6 +26,8 @@
"setting": "Settings",
"mobileConnect": "Mobile Connect",
"connectRemote": "Connect Remote",
"remoteDevice": "Remote Device",
"remoteDeviceList": "Remote Device List",
"myQRCode": "My QR Code",
"theme": "Theme",
@@ -227,12 +229,14 @@
"appExitTips": "Press again to exit the program",
"remoteConnectDisconnect": "Check remote connection failed, disconnected",
"reconnect": "Reconnect",
"remoteConnected": "Connected {os}Mobile packet capture is turned off",
"remoteConnected": "Connected {os}, traffic will be forwarded to {os}",
"remoteConnectForward": "Remote connection, forwarding requests to other terminals",
"connectSuccess": "Connect successful",
"connectedRemote": "Connected to remote",
"connected": "Connected",
"notConnected": "Not connected",
"disconnect": "Disconnect",
"inputAddress": "Input Address",
"syncConfig": "Sync configuration",
"pullConfigFail": "Failed to pull configuration, please check the network connection",
"sync": "Sync",

View File

@@ -7,7 +7,7 @@
"preference": "偏好设置",
"feedback": "反馈",
"about": "关于",
"filter": "过滤",
"filter": "代理过滤",
"script": "脚本",
"share": "分享",
"port": "端口号: ",
@@ -26,6 +26,8 @@
"setting": "设置",
"mobileConnect": "手机连接",
"connectRemote": "连接终端",
"remoteDevice": "远程设备",
"remoteDeviceList": "远程设备列表",
"myQRCode": "我的二维码",
"theme": "主题",
@@ -227,11 +229,13 @@
"appExitTips": "再按一次退出程序",
"remoteConnectDisconnect": "检查远程连接失败,已断开",
"reconnect": "重新连接",
"remoteConnected": "已连接{os}手机抓包已关闭",
"remoteConnectForward": "远程连接,将请求转发到其他终端",
"remoteConnected": "已连接{os}流量将转发到{os}",
"remoteConnectForward": "远程连接,将其他设备流量转发到当前设备",
"connectSuccess": "连接成功",
"connectedRemote": "已连接远程",
"connected": "已连接",
"notConnected": "未连接",
"inputAddress": "输入地址",
"disconnect": "断开连接",
"syncConfig": "同步配置",
"pullConfigFail": "拉取配置失败, 请检查网络连接",

View File

@@ -82,7 +82,7 @@ class _PhoneConnectState extends State<PhoneConnect> {
items: widget.hosts
.map((it) => DropdownMenuItem(
value: it,
child: Text('$it:$port'),
child: SelectableText('$it:$port'),
))
.toList(),
onChanged: (String? value) {

View File

@@ -116,10 +116,11 @@ class _SocketLaunchState extends State<SocketLaunch> with WindowListener, Widget
@override
Widget build(BuildContext context) {
Color primaryColor = Theme.of(context).primaryColor;
return IconButton(
tooltip: started ? localizations.stop : localizations.start,
icon: Icon(started ? Icons.stop : Icons.play_arrow_sharp,
color: started ? Colors.red : Colors.green, size: widget.size.toDouble()),
color: started ? Colors.red : primaryColor, size: widget.size.toDouble()),
onPressed: () async {
if (started) {
if (!widget.serverLaunch) {

View File

@@ -16,31 +16,21 @@
import 'dart:io';
import 'package:date_format/date_format.dart';
import 'package:easy_permission/easy_permission.dart';
import 'package:flutter/material.dart';
import 'package:flutter_barcode_scanner/flutter_barcode_scanner.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_toastr/flutter_toastr.dart';
import 'package:network_proxy/native/vpn.dart';
import 'package:network_proxy/network/bin/server.dart';
import 'package:network_proxy/network/http_client.dart';
import 'package:network_proxy/network/util/logger.dart';
import 'package:network_proxy/ui/mobile/mobile.dart';
import 'package:network_proxy/ui/mobile/setting/app_filter.dart';
import 'package:network_proxy/ui/mobile/setting/ssl.dart';
import 'package:network_proxy/ui/mobile/widgets/connect_remote.dart';
import 'package:network_proxy/ui/mobile/widgets/highlight.dart';
import 'package:network_proxy/utils/ip.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:qrscan/qrscan.dart' as scanner;
import 'package:url_launcher/url_launcher.dart';
import 'package:network_proxy/ui/mobile/widgets/remote_device.dart';
/// +号菜单
class MoreMenu extends StatelessWidget {
final ProxyServer proxyServer;
final ValueNotifier<RemoteModel> desktop;
final ValueNotifier<RemoteModel> remoteDevice;
const MoreMenu({super.key, required this.proxyServer, required this.desktop});
const MoreMenu({super.key, required this.proxyServer, required this.remoteDevice});
@override
Widget build(BuildContext context) {
@@ -74,25 +64,11 @@ class MoreMenu extends StatelessWidget {
height: 32,
child: ListTile(
dense: true,
leading: const Icon(Icons.qr_code_scanner_outlined),
title: Text(localizations.connectRemote),
leading: const Icon(Icons.devices),
title: Text(localizations.remoteDevice),
onTap: () {
Navigator.maybePop(context);
connectRemote(context);
},
)),
PopupMenuItem(
height: 32,
child: ListTile(
dense: true,
leading: const Icon(Icons.phone_iphone_outlined),
title: Text(localizations.myQRCode),
onTap: () async {
Navigator.maybePop(context);
var ip = await localIp(readCache: false);
if (context.mounted) {
connectQrCode(context, ip, proxyServer.port);
}
navigator(context, RemoteDevicePage(proxyServer: proxyServer, remoteDevice: remoteDevice));
},
)),
const PopupMenuDivider(height: 0),
@@ -143,108 +119,4 @@ class MoreMenu extends StatelessWidget {
);
}
}
///扫码连接
connectRemote(BuildContext context) async {
AppLocalizations localizations = AppLocalizations.of(context)!;
String scanRes;
if (Platform.isAndroid) {
await EasyPermission.requestPermissions([PermissionType.CAMERA]);
scanRes = await scanner.scan() ?? "-1";
} else {
scanRes = await FlutterBarcodeScanner.scanBarcode("#ff6666", localizations.cancel, true, ScanMode.QR);
}
if (scanRes == "-1") return;
if (scanRes.startsWith("http")) {
launchUrl(Uri.parse(scanRes), mode: LaunchMode.externalApplication);
return;
}
if (scanRes.startsWith("proxypin://connect")) {
Uri uri = Uri.parse(scanRes);
var host = uri.queryParameters['host'];
var port = uri.queryParameters['port'];
try {
var response = await HttpClients.get("http://$host:$port/ping").timeout(const Duration(seconds: 1));
if (response.bodyAsString == "pong") {
desktop.value = RemoteModel(
connect: true,
host: host,
port: int.parse(port!),
os: response.headers.get("os"),
hostname: response.headers.get("hostname"));
if (context.mounted && Navigator.canPop(context)) {
FlutterToastr.show(
"${localizations.connectSuccess}${Vpn.isVpnStarted ? '' : ', ${localizations.remoteConnectSuccessTips}'}",
context,
duration: 3);
Navigator.pop(context);
}
}
} catch (e) {
logger.e(e);
if (context.mounted) {
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(content: Text(localizations.remoteConnectFail));
});
}
}
return;
}
if (context.mounted) {
FlutterToastr.show(localizations.invalidQRCode, context);
}
}
///连接二维码
connectQrCode(BuildContext context, String host, int port) {
AppLocalizations localizations = AppLocalizations.of(context)!;
showDialog(
context: context,
builder: (context) {
return AlertDialog(
contentPadding: const EdgeInsets.only(top: 5),
actionsPadding: const EdgeInsets.only(bottom: 5),
title: Text(localizations.remoteConnectForward, style: const TextStyle(fontSize: 16)),
content: SizedBox(
height: 260,
width: 300,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
QrImageView(
backgroundColor: Colors.white,
data: "proxypin://connect?host=$host&port=${proxyServer.port}",
version: QrVersions.auto,
size: 200.0,
),
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('${localizations.localIP}:'),
const SizedBox(width: 5),
SelectableText('$host:$port'),
],
),
const SizedBox(height: 10),
Text(localizations.mobileScan),
],
)),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: Text(localizations.cancel)),
],
);
});
}
}

View File

@@ -41,8 +41,8 @@ import 'package:network_proxy/ui/mobile/menu/me.dart';
import 'package:network_proxy/ui/mobile/menu/menu.dart';
import 'package:network_proxy/ui/mobile/request/list.dart';
import 'package:network_proxy/ui/mobile/request/search.dart';
import 'package:network_proxy/ui/mobile/widgets/connect_remote.dart';
import 'package:network_proxy/ui/mobile/widgets/pip.dart';
import 'package:network_proxy/ui/mobile/widgets/remote_device.dart';
import 'package:network_proxy/utils/ip.dart';
import 'package:network_proxy/utils/lang.dart';
import 'package:network_proxy/utils/listenable_list.dart';
@@ -177,7 +177,7 @@ class MobileHomeState extends State<MobileHomePage> implements EventListener, Li
body: LazyIndexedStack(index: index, children: navigationView),
bottomNavigationBar: widget.appConfiguration.bottomNavigation
? Container(
constraints: const BoxConstraints(maxHeight: 72),
constraints: const BoxConstraints(maxHeight: 80),
child: Theme(
data: Theme.of(context).copyWith(splashColor: Colors.transparent),
child: BottomNavigationBar(
@@ -249,7 +249,7 @@ class MobileHomeState extends State<MobileHomePage> implements EventListener, Li
String content = isCN
? '提示默认不会开启HTTPS抓包请安装证书后再开启HTTPS抓包。\n\n'
'1. 手机端增加底部导航,可在设置中切换;\n'
'2. 外部代理支持身份验证\n'
'2. 增加远程设备管理,可快速连接设备\n'
'3. 双击列表tab滚动到顶部\n'
'4. 修复部分p12证书导入失败的问题\n'
'5. 脚本增加rawBody原始字节参数, body支持字节数组修改\n'
@@ -261,7 +261,7 @@ class MobileHomeState extends State<MobileHomePage> implements EventListener, Li
: 'TipsBy default, HTTPS packet capture will not be enabled. Please install the certificate before enabling HTTPS packet capture。\n\n'
'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. External proxy support authentication\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'
'5. The script add rawBody raw byte parameter, body supports byte array modification\n'
@@ -310,7 +310,7 @@ class RequestPage extends StatefulWidget {
class RequestPageState extends State<RequestPage> {
/// 远程连接
final ValueNotifier<RemoteModel> desktop = ValueNotifier(RemoteModel(connect: false));
final ValueNotifier<RemoteModel> remoteDevice = ValueNotifier(RemoteModel(connect: false));
late ProxyServer proxyServer;
@@ -322,9 +322,9 @@ class RequestPageState extends State<RequestPage> {
proxyServer = widget.proxyServer;
//远程连接
desktop.addListener(() {
if (desktop.value.connect) {
proxyServer.configuration.remoteHost = "http://${desktop.value.host}:${desktop.value.port}";
remoteDevice.addListener(() {
if (remoteDevice.value.connect) {
proxyServer.configuration.remoteHost = "http://${remoteDevice.value.host}:${remoteDevice.value.port}";
checkConnectTask(context);
} else {
proxyServer.configuration.remoteHost = null;
@@ -334,7 +334,7 @@ class RequestPageState extends State<RequestPage> {
@override
void dispose() {
desktop.dispose();
remoteDevice.dispose();
super.dispose();
}
@@ -343,13 +343,13 @@ class RequestPageState extends State<RequestPage> {
return Scaffold(
floatingActionButton: PictureInPictureIcon(proxyServer),
body: Scaffold(
appBar: _MobileAppBar(widget.appConfiguration, proxyServer, desktop: desktop),
appBar: _MobileAppBar(widget.appConfiguration, proxyServer, remoteDevice: remoteDevice),
drawer: widget.appConfiguration.bottomNavigation
? null
: DrawerWidget(proxyServer: proxyServer, container: MobileApp.container),
floatingActionButton: _launchActionButton(),
body: ValueListenableBuilder(
valueListenable: desktop,
valueListenable: remoteDevice,
builder: (context, value, _) {
return Column(children: [
value.connect ? remoteConnect(value) : const SizedBox(),
@@ -387,13 +387,16 @@ class RequestPageState extends State<RequestPage> {
Widget remoteConnect(RemoteModel value) {
return Container(
margin: const EdgeInsets.only(top: 5, bottom: 5),
height: 55,
height: 56,
width: double.infinity,
child: ElevatedButton(
style: ButtonStyle(
shape: WidgetStateProperty.all<RoundedRectangleBorder>(
RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)))),
onPressed: () => Navigator.of(context).push(MaterialPageRoute(builder: (BuildContext context) {
return ConnectRemote(desktop: desktop, proxyServer: proxyServer);
return RemoteDevicePage(remoteDevice: remoteDevice, proxyServer: proxyServer);
})),
child: Text(localizations.remoteConnected(desktop.value.os ?? ''),
child: Text(localizations.remoteConnected(remoteDevice.value.os ?? ', ${remoteDevice.value.hostname}'),
style: Theme.of(context).textTheme.titleMedium),
));
}
@@ -401,14 +404,14 @@ class RequestPageState extends State<RequestPage> {
/// 检查远程连接
checkConnectTask(BuildContext context) async {
int retry = 0;
Timer.periodic(const Duration(milliseconds: 3000), (timer) async {
if (desktop.value.connect == false) {
Timer.periodic(const Duration(milliseconds: 10000), (timer) async {
if (remoteDevice.value.connect == false) {
timer.cancel();
return;
}
try {
var response = await HttpClients.get("http://${desktop.value.host}:${desktop.value.port}/ping")
var response = await HttpClients.get("http://${remoteDevice.value.host}:${remoteDevice.value.port}/ping")
.timeout(const Duration(seconds: 1));
if (response.bodyAsString == "pong") {
retry = 0;
@@ -419,13 +422,16 @@ class RequestPageState extends State<RequestPage> {
}
if (retry > 5) {
timer.cancel();
desktop.value = RemoteModel(connect: false);
retry = 0;
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(localizations.remoteConnectDisconnect),
action: SnackBarAction(
label: localizations.reconnect, onPressed: () => desktop.value = RemoteModel(connect: true))));
label: localizations.disconnect,
onPressed: () {
timer.cancel();
remoteDevice.value = RemoteModel(connect: false);
})));
}
}
});
@@ -436,9 +442,9 @@ class RequestPageState extends State<RequestPage> {
class _MobileAppBar extends StatelessWidget implements PreferredSizeWidget {
final AppConfiguration appConfiguration;
final ProxyServer proxyServer;
final ValueNotifier<RemoteModel> desktop;
final ValueNotifier<RemoteModel> remoteDevice;
const _MobileAppBar(this.appConfiguration, this.proxyServer, {required this.desktop});
const _MobileAppBar(this.appConfiguration, this.proxyServer, {required this.remoteDevice});
@override
Size get preferredSize => const Size.fromHeight(42);
@@ -458,7 +464,7 @@ class _MobileAppBar extends StatelessWidget implements PreferredSizeWidget {
icon: const Icon(Icons.cleaning_services_outlined),
onPressed: () => MobileApp.requestStateKey.currentState?.clean()),
const SizedBox(width: 2),
MoreMenu(proxyServer: proxyServer, desktop: desktop),
MoreMenu(proxyServer: proxyServer, remoteDevice: remoteDevice),
const SizedBox(width: 10),
]);
}

View File

@@ -1,218 +0,0 @@
/*
* Copyright 2023 Hongen Wang
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:network_proxy/network/bin/configuration.dart';
import 'package:network_proxy/network/bin/server.dart';
import 'package:network_proxy/network/components/host_filter.dart';
import 'package:network_proxy/network/components/request_rewrite_manager.dart';
import 'package:network_proxy/network/components/script_manager.dart';
import 'package:network_proxy/network/http_client.dart';
import 'package:network_proxy/network/util/logger.dart';
class RemoteModel {
final bool connect;
final String? host;
final int? port;
final String? os;
final String? hostname;
RemoteModel({
required this.connect,
this.host,
this.port,
this.os,
this.hostname,
});
}
class ConnectRemote extends StatefulWidget {
final ProxyServer proxyServer;
final ValueNotifier<RemoteModel> desktop;
const ConnectRemote({super.key, required this.desktop, required this.proxyServer});
@override
State<StatefulWidget> createState() {
return ConnectRemoteState();
}
}
class ConnectRemoteState extends State<ConnectRemote> {
bool syncConfig = false;
AppLocalizations get localizations => AppLocalizations.of(context)!;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(localizations.connectedRemote, style: const TextStyle(fontSize: 16))),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('${localizations.connected}${widget.desktop.value.hostname}',
style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 10),
OutlinedButton(
child: Text(localizations.disconnect),
onPressed: () {
widget.desktop.value = RemoteModel(connect: false);
Navigator.pop(context);
}),
const SizedBox(height: 10),
OutlinedButton(
child: Text(localizations.syncConfig),
onPressed: () {
pullConfig();
},
),
],
),
),
);
}
//拉取桌面配置
pullConfig() {
var desktopModel = widget.desktop.value;
HttpClients.get('http://${desktopModel.host}:${desktopModel.port}/config').then((response) {
if (response.status.isSuccessful()) {
var config = jsonDecode(response.bodyAsString);
syncConfig = true;
showDialog(
context: context,
builder: (context) {
return ConfigSyncWidget(configuration: widget.proxyServer.configuration, config: config);
});
}
}).onError((error, stackTrace) {
logger.e('拉取配置失败', error: error, stackTrace: stackTrace);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(localizations.pullConfigFail)));
});
}
}
class ConfigSyncWidget extends StatefulWidget {
final Configuration configuration;
final Map<String, dynamic> config;
const ConfigSyncWidget({super.key, required this.configuration, required this.config});
@override
State<StatefulWidget> createState() {
return ConfigSyncState();
}
}
class ConfigSyncState extends State<ConfigSyncWidget> {
bool syncWhiteList = true;
bool syncBlackList = true;
bool syncRewrite = true;
bool syncScript = true;
AppLocalizations get localizations => AppLocalizations.of(context)!;
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(localizations.syncConfig, style: const TextStyle(fontSize: 16)),
content: SizedBox(
height: 260,
child: Column(
children: [
SwitchListTile(
dense: true,
subtitle: Text("${localizations.sync}${localizations.domainWhitelist}"),
value: syncWhiteList,
onChanged: (val) {
setState(() {
syncWhiteList = val;
});
}),
SwitchListTile(
dense: true,
subtitle: Text("${localizations.sync}${localizations.domainBlacklist}"),
value: syncBlackList,
onChanged: (val) {
setState(() {
syncBlackList = val;
});
}),
SwitchListTile(
dense: true,
subtitle: Text("${localizations.sync}${localizations.requestRewrite}"),
value: syncRewrite,
onChanged: (val) {
setState(() {
syncRewrite = val;
});
}),
SwitchListTile(
dense: true,
subtitle: Text("${localizations.sync}${localizations.script}"),
value: syncScript,
onChanged: (val) {
setState(() {
syncScript = val;
});
}),
],
)),
actions: [
TextButton(
child: Text(localizations.cancel),
onPressed: () {
Navigator.pop(context);
}),
TextButton(
child: Text('${localizations.start}${localizations.sync}'),
onPressed: () async {
if (syncWhiteList) {
HostFilter.whitelist.load(widget.config['whitelist']);
}
if (syncBlackList) {
HostFilter.blacklist.load(widget.config['blacklist']);
}
widget.configuration.flushConfig();
if (syncRewrite) {
var requestRewrites = await RequestRewrites.instance;
await requestRewrites.syncConfig(widget.config['requestRewrites']);
}
if (syncScript) {
var scriptManager = await ScriptManager.instance;
await scriptManager.clean();
scriptManager.list.clear();
for (var item in widget.config['scripts']) {
await scriptManager.addScript(ScriptItem.fromJson(item), item['script']);
}
await scriptManager.flushConfig();
}
if (mounted) {
Navigator.pop(this.context);
ScaffoldMessenger.of(this.context)
.showSnackBar(SnackBar(content: Text('${localizations.sync}${localizations.success}')));
}
}),
],
);
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2023 Hongen Wang
* Copyright 2023 Hongen Wang All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.

View File

@@ -0,0 +1,468 @@
/*
* Copyright 2024 Hongen Wang All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'dart:convert';
import 'dart:io';
import 'package:easy_permission/easy_permission.dart';
import 'package:flutter/material.dart';
import 'package:flutter_barcode_scanner/flutter_barcode_scanner.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_toastr/flutter_toastr.dart';
import 'package:network_proxy/native/vpn.dart';
import 'package:network_proxy/network/bin/configuration.dart';
import 'package:network_proxy/network/bin/server.dart';
import 'package:network_proxy/network/components/host_filter.dart';
import 'package:network_proxy/network/components/request_rewrite_manager.dart';
import 'package:network_proxy/network/components/script_manager.dart';
import 'package:network_proxy/network/http_client.dart';
import 'package:network_proxy/network/util/logger.dart';
import 'package:network_proxy/ui/component/utils.dart';
import 'package:network_proxy/ui/component/widgets.dart';
import 'package:network_proxy/utils/ip.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:qrscan/qrscan.dart' as scanner;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:url_launcher/url_launcher.dart';
///远程设备
///Remote device
///@author Hongen Wang
class RemoteModel {
final bool connect;
final String? host;
final int? port;
final String? os;
final String? hostname;
RemoteModel({
required this.connect,
this.host,
this.port,
this.os,
this.hostname,
});
factory RemoteModel.fromJson(Map<String, dynamic> json) {
return RemoteModel(
connect: json['connect'],
host: json['host'],
port: json['port'],
os: json['os'],
hostname: json['hostname'],
);
}
Map<String, dynamic> toJson() {
return {
'connect': connect,
'host': host,
'port': port,
'os': os,
'hostname': hostname,
};
}
}
class RemoteDevicePage extends StatefulWidget {
final ProxyServer proxyServer;
final ValueNotifier<RemoteModel> remoteDevice;
const RemoteDevicePage({super.key, required this.proxyServer, required this.remoteDevice});
@override
State<RemoteDevicePage> createState() => _RemoteDevicePageState();
}
class _RemoteDevicePageState extends State<RemoteDevicePage> {
AppLocalizations get localizations => AppLocalizations.of(context)!;
bool syncConfig = false;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
centerTitle: true,
title: Text(localizations.remoteDevice, style: const TextStyle(fontSize: 16)),
actions: [
PopupMenuButton(
icon: const Icon(Icons.add_outlined),
itemBuilder: (BuildContext context) {
return <PopupMenuEntry>[
CustomPopupMenuItem(
height: 32,
child: ListTile(
leading: const Icon(Icons.qr_code_scanner_outlined),
dense: true,
title: Text(localizations.scanCode),
onTap: () => connectRemote(context))),
// CustomPopupMenuItem(
// height: 32,
// child: ListTile(
// leading: const Icon(Icons.edit_rounded),
// dense: true,
// title: Text(localizations.inputAddress),
// onTap: () {})),
PopupMenuItem(
height: 32,
child: ListTile(
dense: true,
leading: const Icon(Icons.phone_android),
title: Text(localizations.myQRCode),
onTap: () async {
Navigator.maybePop(context);
var ip = await localIp(readCache: false);
if (context.mounted) {
qrCode(context, ip, widget.proxyServer.port);
}
},
)),
];
},
),
const SizedBox(width: 10),
],
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
remoteDeviceStatus(), //远程设备状态
const SizedBox(height: 20),
Text(localizations.remoteDeviceList, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500)),
const SizedBox(height: 10),
Expanded(child: futureWidget(SharedPreferences.getInstance(), rows)), //远程设备列表
],
),
),
);
}
Widget rows(SharedPreferences prefs) {
var remoteDeviceList = prefs.getStringList('remoteDeviceList') ?? [];
return ListView(
children: remoteDeviceList.map((it) {
var remoteDevice = RemoteModel.fromJson(jsonDecode(it));
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 5),
title: Text(remoteDevice.hostname ?? ''),
subtitle: Text('${remoteDevice.host}:${remoteDevice.port}'),
trailing: getIcon(remoteDevice.os!),
onTap: () {
doConnect(remoteDevice.host!, remoteDevice.port!);
},
);
}).toList(),
);
}
Icon getIcon(String os) {
if (os.contains("windows")) {
return const Icon(Icons.window_sharp, size: 30);
} else if (os.contains("linux")) {
return const Icon(Icons.desktop_windows, size: 30);
} else if (os.contains("macos") || os.contains("ios")) {
return const Icon(Icons.apple, size: 30);
} else if (os == 'android') {
return const Icon(Icons.android, size: 30);
} else {
return const Icon(Icons.devices, size: 30);
}
}
///远程设备状态
Widget remoteDeviceStatus() {
if (widget.remoteDevice.value.connect) {
return Center(
child: Column(
children: [
const Icon(Icons.check_circle_outline_outlined, size: 55, color: Colors.green),
Text('${localizations.connected}${widget.remoteDevice.value.hostname}',
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500)),
const SizedBox(height: 6),
Row(mainAxisAlignment: MainAxisAlignment.spaceAround, children: [
TextButton.icon(
style: ButtonStyle(
shape: WidgetStateProperty.all<RoundedRectangleBorder>(
RoundedRectangleBorder(borderRadius: BorderRadius.circular(8.0)))),
onPressed: pullConfig,
icon: const Icon(Icons.sync),
label: Text(localizations.syncConfig),
),
TextButton.icon(
label: Text(localizations.disconnect),
style: ButtonStyle(
shape: WidgetStateProperty.all<RoundedRectangleBorder>(
RoundedRectangleBorder(borderRadius: BorderRadius.circular(8.0))),
),
icon: const Icon(Icons.cancel_outlined),
onPressed: () {
widget.remoteDevice.value = RemoteModel(connect: false);
setState(() {});
},
),
])
],
));
}
return Center(
child: Column(children: [
const Icon(Icons.cancel_outlined, size: 55, color: Colors.red),
const SizedBox(height: 6),
Text(localizations.notConnected, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500)),
]));
}
///扫码连接
connectRemote(BuildContext context) async {
AppLocalizations localizations = AppLocalizations.of(context)!;
String scanRes;
if (Platform.isAndroid) {
await EasyPermission.requestPermissions([PermissionType.CAMERA]);
scanRes = await scanner.scan() ?? "-1";
} else {
scanRes = await FlutterBarcodeScanner.scanBarcode("#ff6666", localizations.cancel, true, ScanMode.QR);
}
if (scanRes == "-1") return;
if (scanRes.startsWith("http")) {
launchUrl(Uri.parse(scanRes), mode: LaunchMode.externalApplication);
return;
}
if (scanRes.startsWith("proxypin://connect")) {
Uri uri = Uri.parse(scanRes);
var host = uri.queryParameters['host'];
var port = uri.queryParameters['port'];
doConnect(host!, int.parse(port!));
}
if (context.mounted) {
FlutterToastr.show(localizations.invalidQRCode, context);
}
}
doConnect(String host, int port) async {
try {
var response = await HttpClients.get("http://$host:$port/ping").timeout(const Duration(seconds: 3));
if (response.bodyAsString == "pong") {
widget.remoteDevice.value = RemoteModel(
connect: true,
host: host,
port: port,
os: response.headers.get("os"),
hostname: response.headers.get("hostname"));
//去重记录5条连接记录
SharedPreferences prefs = await SharedPreferences.getInstance();
var remoteDeviceList = prefs.getStringList('remoteDeviceList') ?? [];
var value = jsonEncode(widget.remoteDevice.value.toJson());
remoteDeviceList.remove(value);
remoteDeviceList.insert(0, value);
prefs.setStringList('remoteDeviceList', remoteDeviceList).then((value) {
setState(() {});
});
if (mounted && Navigator.canPop(context)) {
FlutterToastr.show(
"${localizations.connectSuccess}${Vpn.isVpnStarted ? '' : ', ${localizations.remoteConnectSuccessTips}'}",
context,
duration: 3);
}
}
} catch (e) {
logger.e(e);
if (mounted) {
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(content: Text(localizations.remoteConnectFail));
});
}
}
}
///连接二维码
qrCode(BuildContext context, String host, int port) {
AppLocalizations localizations = AppLocalizations.of(context)!;
showDialog(
context: context,
builder: (context) {
return AlertDialog(
contentPadding: const EdgeInsets.all(15),
actionsPadding: const EdgeInsets.only(bottom: 10, right: 10),
title: Text(localizations.remoteConnectForward, style: const TextStyle(fontSize: 16)),
content: SizedBox(
height: 280,
width: 300,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
QrImageView(
backgroundColor: Colors.white,
data: "proxypin://connect?host=$host&port=${widget.proxyServer.port}",
version: QrVersions.auto,
size: 200.0,
),
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('${localizations.localIP}:'),
const SizedBox(width: 5),
SelectableText('$host:$port'),
],
),
const SizedBox(height: 10),
Text(localizations.mobileScan),
],
)),
actions: [
TextButton(onPressed: () => Navigator.of(context).pop(), child: Text(localizations.cancel)),
],
);
});
}
//拉取桌面配置
pullConfig() {
var desktopModel = widget.remoteDevice.value;
HttpClients.get('http://${desktopModel.host}:${desktopModel.port}/config').then((response) {
if (response.status.isSuccessful()) {
var config = jsonDecode(response.bodyAsString);
syncConfig = true;
showDialog(
context: context,
builder: (context) {
return ConfigSyncWidget(configuration: widget.proxyServer.configuration, config: config);
});
}
}).onError((error, stackTrace) {
logger.e('拉取配置失败', error: error, stackTrace: stackTrace);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(localizations.pullConfigFail)));
});
}
}
class ConfigSyncWidget extends StatefulWidget {
final Configuration configuration;
final Map<String, dynamic> config;
const ConfigSyncWidget({super.key, required this.configuration, required this.config});
@override
State<StatefulWidget> createState() {
return ConfigSyncState();
}
}
class ConfigSyncState extends State<ConfigSyncWidget> {
bool syncWhiteList = true;
bool syncBlackList = true;
bool syncRewrite = true;
bool syncScript = true;
AppLocalizations get localizations => AppLocalizations.of(context)!;
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(localizations.syncConfig, style: const TextStyle(fontSize: 16)),
content: Wrap(children: [
SwitchWidget(
title: "${localizations.sync}${localizations.domainWhitelist}",
value: syncWhiteList,
onChanged: (val) {
setState(() {
syncWhiteList = val;
});
}),
const SizedBox(height: 5),
SwitchWidget(
title: "${localizations.sync}${localizations.domainBlacklist}",
value: syncBlackList,
onChanged: (val) {
setState(() {
syncBlackList = val;
});
}),
const SizedBox(height: 5),
SwitchWidget(
title: "${localizations.sync}${localizations.requestRewrite}",
value: syncRewrite,
onChanged: (val) {
setState(() {
syncRewrite = val;
});
}),
const SizedBox(height: 5),
SwitchWidget(
title: "${localizations.sync}${localizations.script}",
value: syncScript,
onChanged: (val) {
setState(() {
syncScript = val;
});
}),
]),
actions: [
TextButton(
child: Text(localizations.cancel),
onPressed: () {
Navigator.pop(context);
}),
TextButton(
child: Text('${localizations.start}${localizations.sync}'),
onPressed: () async {
if (syncWhiteList) {
HostFilter.whitelist.load(widget.config['whitelist']);
}
if (syncBlackList) {
HostFilter.blacklist.load(widget.config['blacklist']);
}
widget.configuration.flushConfig();
if (syncRewrite) {
var requestRewrites = await RequestRewrites.instance;
await requestRewrites.syncConfig(widget.config['requestRewrites']);
}
if (syncScript) {
var scriptManager = await ScriptManager.instance;
await scriptManager.clean();
scriptManager.list.clear();
for (var item in widget.config['scripts']) {
await scriptManager.addScript(ScriptItem.fromJson(item), item['script']);
}
await scriptManager.flushConfig();
}
if (mounted) {
Navigator.pop(this.context);
ScaffoldMessenger.of(this.context)
.showSnackBar(SnackBar(content: Text('${localizations.sync}${localizations.success}')));
}
}),
],
);
}
}