From aded2b7f37afef0e875702ec57e32d6fa8b57e80 Mon Sep 17 00:00:00 2001 From: wanghongenpin Date: Sun, 6 Oct 2024 17:28:15 +0800 Subject: [PATCH] Support remote device management --- lib/l10n/app_en.arb | 8 +- lib/l10n/app_zh.arb | 10 +- lib/ui/desktop/toolbar/phone_connect.dart | 2 +- lib/ui/launch/launch.dart | 3 +- lib/ui/mobile/menu/menu.dart | 140 +------ lib/ui/mobile/mobile.dart | 52 +-- lib/ui/mobile/widgets/connect_remote.dart | 218 ---------- lib/ui/mobile/widgets/highlight.dart | 2 +- lib/ui/mobile/widgets/remote_device.dart | 468 ++++++++++++++++++++++ 9 files changed, 520 insertions(+), 383 deletions(-) delete mode 100644 lib/ui/mobile/widgets/connect_remote.dart create mode 100644 lib/ui/mobile/widgets/remote_device.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index a65a7f5..2c3213a 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 2b90b3f..9f5c55d 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -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": "拉取配置失败, 请检查网络连接", diff --git a/lib/ui/desktop/toolbar/phone_connect.dart b/lib/ui/desktop/toolbar/phone_connect.dart index 41de29a..ef5e2ac 100644 --- a/lib/ui/desktop/toolbar/phone_connect.dart +++ b/lib/ui/desktop/toolbar/phone_connect.dart @@ -82,7 +82,7 @@ class _PhoneConnectState extends State { items: widget.hosts .map((it) => DropdownMenuItem( value: it, - child: Text('$it:$port'), + child: SelectableText('$it:$port'), )) .toList(), onChanged: (String? value) { diff --git a/lib/ui/launch/launch.dart b/lib/ui/launch/launch.dart index 103a700..be1bc57 100644 --- a/lib/ui/launch/launch.dart +++ b/lib/ui/launch/launch.dart @@ -116,10 +116,11 @@ class _SocketLaunchState extends State 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) { diff --git a/lib/ui/mobile/menu/menu.dart b/lib/ui/mobile/menu/menu.dart index 5c8d4bc..3c258f8 100644 --- a/lib/ui/mobile/menu/menu.dart +++ b/lib/ui/mobile/menu/menu.dart @@ -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 desktop; + final ValueNotifier 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)), - ], - ); - }); - } } diff --git a/lib/ui/mobile/mobile.dart b/lib/ui/mobile/mobile.dart index 860c861..f34bf7e 100644 --- a/lib/ui/mobile/mobile.dart +++ b/lib/ui/mobile/mobile.dart @@ -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 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 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 implements EventListener, Li : 'Tips:By 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 bar,which 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 { /// 远程连接 - final ValueNotifier desktop = ValueNotifier(RemoteModel(connect: false)); + final ValueNotifier remoteDevice = ValueNotifier(RemoteModel(connect: false)); late ProxyServer proxyServer; @@ -322,9 +322,9 @@ class RequestPageState extends State { 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 { @override void dispose() { - desktop.dispose(); + remoteDevice.dispose(); super.dispose(); } @@ -343,13 +343,13 @@ class RequestPageState extends State { 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 { 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(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 { /// 检查远程连接 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 { } 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 { class _MobileAppBar extends StatelessWidget implements PreferredSizeWidget { final AppConfiguration appConfiguration; final ProxyServer proxyServer; - final ValueNotifier desktop; + final ValueNotifier 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), ]); } diff --git a/lib/ui/mobile/widgets/connect_remote.dart b/lib/ui/mobile/widgets/connect_remote.dart deleted file mode 100644 index ef04bc9..0000000 --- a/lib/ui/mobile/widgets/connect_remote.dart +++ /dev/null @@ -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 desktop; - - const ConnectRemote({super.key, required this.desktop, required this.proxyServer}); - - @override - State createState() { - return ConnectRemoteState(); - } -} - -class ConnectRemoteState extends State { - 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 config; - - const ConfigSyncWidget({super.key, required this.configuration, required this.config}); - - @override - State createState() { - return ConfigSyncState(); - } -} - -class ConfigSyncState extends State { - 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}'))); - } - }), - ], - ); - } -} diff --git a/lib/ui/mobile/widgets/highlight.dart b/lib/ui/mobile/widgets/highlight.dart index 6a8179a..575a014 100644 --- a/lib/ui/mobile/widgets/highlight.dart +++ b/lib/ui/mobile/widgets/highlight.dart @@ -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. diff --git a/lib/ui/mobile/widgets/remote_device.dart b/lib/ui/mobile/widgets/remote_device.dart new file mode 100644 index 0000000..600c353 --- /dev/null +++ b/lib/ui/mobile/widgets/remote_device.dart @@ -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 json) { + return RemoteModel( + connect: json['connect'], + host: json['host'], + port: json['port'], + os: json['os'], + hostname: json['hostname'], + ); + } + + Map toJson() { + return { + 'connect': connect, + 'host': host, + 'port': port, + 'os': os, + 'hostname': hostname, + }; + } +} + +class RemoteDevicePage extends StatefulWidget { + final ProxyServer proxyServer; + final ValueNotifier remoteDevice; + + const RemoteDevicePage({super.key, required this.proxyServer, required this.remoteDevice}); + + @override + State createState() => _RemoteDevicePageState(); +} + +class _RemoteDevicePageState extends State { + 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 [ + 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(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(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 config; + + const ConfigSyncWidget({super.key, required this.configuration, required this.config}); + + @override + State createState() { + return ConfigSyncState(); + } +} + +class ConfigSyncState extends State { + 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}'))); + } + }), + ], + ); + } +}