Files
proxypin/lib/ui/mobile/widgets/remote_device.dart
2025-06-18 01:51:16 +08:00

606 lines
21 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* 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:flutter/material.dart';
import 'package:proxypin/l10n/app_localizations.dart';
import 'package:flutter_toastr/flutter_toastr.dart';
import 'package:proxypin/native/vpn.dart';
import 'package:proxypin/network/bin/configuration.dart';
import 'package:proxypin/network/bin/server.dart';
import 'package:proxypin/network/components/host_filter.dart';
import 'package:proxypin/network/components/manager/request_rewrite_manager.dart';
import 'package:proxypin/network/components/manager/script_manager.dart';
import 'package:proxypin/network/http/http_client.dart';
import 'package:proxypin/network/util/logger.dart';
import 'package:proxypin/ui/component/app_dialog.dart';
import 'package:proxypin/ui/component/qrcode/qr_scan_view.dart';
import 'package:proxypin/ui/component/utils.dart';
import 'package:proxypin/ui/component/widgets.dart';
import 'package:proxypin/utils/ip.dart';
import 'package:qr_flutter/qr_flutter.dart';
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;
final bool? ipProxy;
RemoteModel({
required this.connect,
this.host,
this.port,
this.os,
this.hostname,
this.ipProxy,
});
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'] == true);
}
RemoteModel copyWith({
bool? connect,
String? host,
int? port,
String? os,
String? hostname,
bool? ipProxy,
}) {
return RemoteModel(
connect: connect ?? this.connect,
host: host ?? this.host,
port: port ?? this.port,
os: os ?? this.os,
hostname: hostname ?? this.hostname,
ipProxy: ipProxy ?? this.ipProxy,
);
}
String get identification => '$host:$port';
//host和端口是否相等
bool equals(RemoteModel remoteModel) {
return identification == remoteModel.identification;
}
Map<String, dynamic> toJson() {
return {'connect': connect, 'host': host, 'port': port, 'os': os, 'hostname': hostname, 'ipProxy': ipProxy};
}
}
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: () {
Navigator.maybePop(context);
connectRemote();
})),
CustomPopupMenuItem(
height: 32,
child: ListTile(
leading: const Icon(Icons.edit_rounded),
dense: true,
title: Text(localizations.inputAddress),
onTap: () async {
Navigator.maybePop(context);
inputAddress(await localIp());
})),
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 = getRemoteDeviceList(prefs);
return ListView(
children: remoteDeviceList.map((remoteDevice) {
return Dismissible(
key: Key(remoteDevice.identification),
onDismissed: (direction) async {
remoteDeviceList.removeWhere((it) => it.equals(remoteDevice));
await setRemoteDeviceList(prefs, remoteDeviceList);
setState(() {});
if (mounted) FlutterToastr.show(localizations.deleteSuccess, context);
},
child: 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!, ipProxy: remoteDevice.ipProxy);
},
));
}).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);
}
}
List<RemoteModel> getRemoteDeviceList(SharedPreferences prefs) {
var remoteDeviceList = prefs.getStringList('remoteDeviceList') ?? [];
return remoteDeviceList.map((it) => RemoteModel.fromJson(jsonDecode(it))).toList();
}
Future<bool> setRemoteDeviceList(SharedPreferences prefs, Iterable<RemoteModel> remoteDeviceList) {
var list = remoteDeviceList.map((it) => jsonEncode(it.toJson())).toList();
return prefs.setStringList('remoteDeviceList', list);
}
///远程设备状态
Widget remoteDeviceStatus() {
if (widget.remoteDevice.value.connect) {
return Center(
child: Column(
children: [
const Icon(Icons.check_circle_outline_outlined, size: 55, color: Colors.green),
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);
});
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),
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)),
]));
}
///输入地址链接
inputAddress(var host) {
//输入账号密码连接
host = host.substring(0, host.contains('.') ? host.lastIndexOf('.') + 1 : host.length);
int? port = 9099;
if (!context.mounted) return;
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: Text(localizations.inputAddress),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
initialValue: host,
decoration: const InputDecoration(hintText: 'Host'),
keyboardType: TextInputType.url,
onChanged: (value) => host = value,
),
TextFormField(
initialValue: port.toString(),
decoration: const InputDecoration(hintText: 'Port'),
keyboardType: TextInputType.number,
onChanged: (value) {
port = value.isEmpty ? null : int.tryParse(value);
}),
],
),
actions: [
TextButton(
onPressed: () {
Navigator.pop(context);
},
child: Text(localizations.cancel)),
TextButton(
onPressed: () async {
if (host.isEmpty || port == null) {
FlutterToastr.show(localizations.cannotBeEmpty, context);
return;
}
if ((await doConnect(host, port!)) && context.mounted) {
Navigator.pop(context);
}
},
child: Text(localizations.connectRemote)),
],
);
});
}
///扫码连接
connectRemote() async {
AppLocalizations localizations = AppLocalizations.of(context)!;
String? scanRes = await QrCodeScanner.scan(context);
if (scanRes == null) return;
if (scanRes == "-1") {
if (context.mounted) FlutterToastr.show(localizations.invalidQRCode, context);
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!));
return;
}
if (mounted) {
FlutterToastr.show(localizations.invalidQRCode, context);
}
}
///
bool doConnecting = false;
///连接远程设备
Future<bool> doConnect(String host, int port, {bool? ipProxy}) async {
if (doConnecting) return false;
doConnecting = true;
try {
var response = await HttpClients.get("http://$host:$port/ping", timeout: const Duration(milliseconds: 3000));
if (response.bodyAsString == "pong") {
widget.remoteDevice.value = RemoteModel(
connect: true,
host: host,
port: port,
os: response.headers.get("os"),
hostname: response.headers.get("hostname"),
ipProxy: ipProxy,
);
//去重记录5条连接记录
SharedPreferences prefs = await SharedPreferences.getInstance();
var remoteDeviceList = getRemoteDeviceList(prefs);
remoteDeviceList.removeWhere((it) => it.equals(widget.remoteDevice.value));
remoteDeviceList.insert(0, widget.remoteDevice.value);
var list = remoteDeviceList.take(5);
setRemoteDeviceList(prefs, list).whenComplete(() {
setState(() {});
});
if (mounted) {
CustomToast.success(
"${localizations.connectSuccess}${Vpn.isVpnStarted ? '' : ', ${localizations.remoteConnectSuccessTips}'}")
.show(context);
}
}
return true;
} catch (e) {
logger.e(e);
if (mounted) {
CustomToast.error(localizations.remoteConnectFail).show(context, alignment: Alignment.topCenter);
}
return false;
} finally {
doConnecting = false;
}
}
///连接二维码
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() && mounted) {
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);
if (mounted) 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 RequestRewriteManager.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}')));
}
}),
],
);
}
}