Desktop support report server

This commit is contained in:
wanghongenpin
2025-10-25 01:49:22 +08:00
parent 346a3cb542
commit aa49bb6226
10 changed files with 653 additions and 10 deletions

View File

@@ -20,6 +20,7 @@ import 'dart:io';
import 'package:proxypin/network/bin/configuration.dart';
import 'package:proxypin/network/components/hosts.dart';
import 'package:proxypin/network/components/interceptor.dart';
import 'package:proxypin/network/components/report_server_interceptor.dart';
import 'package:proxypin/network/components/request_block.dart';
import 'package:proxypin/network/components/request_rewrite.dart';
import 'package:proxypin/network/components/script.dart';
@@ -85,6 +86,7 @@ class ProxyServer {
RequestRewriteInterceptor.instance,
ScriptInterceptor(),
RequestBlockInterceptor(),
ReportServerInterceptor()
];
interceptors.sort((a, b) => a.priority.compareTo(b.priority));

View File

@@ -25,4 +25,8 @@ abstract class Interceptor {
Future<HttpResponse?> onResponse(HttpRequest request, HttpResponse response) async {
return response;
}
Future<void> onError(HttpRequest? request, dynamic error, StackTrace? stackTrace) async {
return;
}
}

View File

@@ -0,0 +1,164 @@
import 'dart:convert';
import '../../../storage/path.dart';
import '../../util/logger.dart';
class ReportServerManager {
static ReportServerManager? _instance;
List<ReportServer> _list = [];
///单例
static Future<ReportServerManager> get instance async {
if (_instance == null) {
_instance = ReportServerManager._internal();
await _instance!.loadConfig();
}
return _instance!;
}
// Private constructor
ReportServerManager._internal();
/// Get configured report servers
List<ReportServer> get servers => _list;
Future<ReportServer?> matchServer(String url) async {
final list = servers;
for (var server in list) {
if (server.match(url)) {
return server;
}
}
return null;
}
Future<void> add(ReportServer server) async {
_list.add(server);
await _flush();
}
Future<void> removeAt(int index) async {
final list = servers;
list.removeAt(index);
await _flush();
}
Future<void> update(int index, ReportServer server) async {
final list = servers;
server.updateUrlReg();
list[index] = server;
await _flush();
}
Future<void> toggleEnabled(int index, bool enabled) async {
final list = servers;
list[index] = list[index].copyWith(enabled: enabled);
await _flush();
}
Future<void> loadConfig() async {
var list = <ReportServer>[];
final file = await Paths.getPath("report_servers.json");
if (await file.exists()) {
final content = await file.readAsString();
if (content.trim().isNotEmpty) {
try {
final decoded = jsonDecode(content) as List<dynamic>;
list = decoded.map((e) => ReportServer.fromJson(e as Map<String, dynamic>)).toList();
} catch (e, t) {
logger.e('上报服务器配置解析失败', error: e, stackTrace: t);
}
}
}
_list = list;
}
Future<void> _flush() async {
final file = await Paths.getPath("report_servers.json");
final list = servers;
await file.writeAsString(jsonEncode(list.map((e) => e.toJson()).toList()));
}
}
class ReportServer {
final String name;
final String matchUrl;
/// 服务器URL
final String serverUrl;
/// 是否启用
final bool enabled;
/// 压缩方式none/gzip默认 none
final String? compression;
/// 额外请求头(可选)
final Map<String, String>? headers;
RegExp _urlReg;
ReportServer({
required this.name,
required this.matchUrl,
required this.serverUrl,
this.enabled = true,
this.compression,
this.headers,
}) : _urlReg = RegExp(matchUrl.replaceAll("*", ".*").replaceFirst('?', '\\?'));
bool match(String url) {
if (enabled) {
return _urlReg.hasMatch(url);
}
return false;
}
void updateUrlReg() {
_urlReg = RegExp(matchUrl.replaceAll("*", ".*").replaceFirst('?', '\\?'));
}
ReportServer copyWith({
String? name,
String? serverUrl,
bool? enabled,
String? matchUrl,
String? matchType,
String? compression,
Map<String, String>? headers,
}) {
return ReportServer(
name: name ?? this.name,
matchUrl: matchUrl ?? this.matchUrl,
serverUrl: serverUrl ?? this.serverUrl,
enabled: enabled ?? this.enabled,
compression: compression ?? this.compression,
headers: headers ?? this.headers,
);
}
factory ReportServer.fromJson(Map<String, dynamic> json) {
final headers = json['headers'];
return ReportServer(
name: json['name'] ?? '',
matchUrl: json['matchUrl'] ?? '',
serverUrl: json['serverUrl'] ?? '',
enabled: json['enabled'] ?? true,
compression: (json['compression'] ?? 'none') as String,
headers: headers == null ? null : Map<String, String>.from(headers as Map),
);
}
Map<String, dynamic> toJson() {
return {
'name': name,
'matchUrl': matchUrl,
'serverUrl': serverUrl,
'enabled': enabled,
'compression': compression,
};
}
}

View File

@@ -0,0 +1,113 @@
/*
* 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:async';
import 'dart:convert';
import 'dart:io';
import 'package:proxypin/network/util/compress.dart';
import 'package:proxypin/network/util/logger.dart';
import 'package:proxypin/utils/har.dart';
import '../http/http.dart';
import 'interceptor.dart';
import 'manager/report_server_manager.dart';
/// Hosts interceptor
/// @author wanghongen
class ReportServerInterceptor extends Interceptor {
Future<ReportServerManager> get reportServerManager async => await ReportServerManager.instance;
static HttpClient httpClient = HttpClient();
@override
int get priority => 1000;
@override
Future<HttpResponse?> onResponse(HttpRequest request, HttpResponse response) async {
// Fire-and-forget reporting; don't block the proxy pipeline
unawaited(reportServer(request, response));
return response;
}
@override
Future<void> onError(HttpRequest? request, error, StackTrace? stackTrace) async {
if (request != null) {
unawaited(reportServer(request, null, error: error, stackTrace: stackTrace));
}
return;
}
Future<void> reportServer(HttpRequest request, HttpResponse? response,
{dynamic error, StackTrace? stackTrace}) async {
String requestUrl = request.requestUrl;
var manager = await reportServerManager;
var server = await manager.matchServer(requestUrl);
if (server == null) {
return;
}
try {
logger.i("reportServer start: $requestUrl -> ${server.name} (${server.serverUrl})");
// Prepare server URL (ensure scheme)
var serverUrl = (server.serverUrl).trim();
if (serverUrl.isEmpty) {
logger.w('reportServer skipped: serverUrl empty for ${server.name}');
return;
}
if (!serverUrl.startsWith('http://') && !serverUrl.startsWith('https://')) {
serverUrl = 'http://$serverUrl';
}
final uri = Uri.parse(serverUrl);
var payload = Har.toHar(request);
List<int> body = utf8.encode(jsonEncode(payload));
// Apply compression if configured
final compression = server.compression?.toLowerCase();
if (compression == 'gzip') {
try {
body = gzipEncode(body);
} catch (e) {
logger.w('reportServer gzip compress failed: $e');
}
}
// Send POST
final ioReq = await httpClient.postUrl(uri).timeout(const Duration(seconds: 5));
// Set headers
ioReq.headers.set(HttpHeaders.contentTypeHeader, 'application/json; charset=utf-8');
if (compression == 'gzip') {
ioReq.headers.set(HttpHeaders.contentEncodingHeader, 'gzip');
}
// Write body and close
ioReq.add(body);
final ioResp = await ioReq.close().timeout(const Duration(seconds: 30));
final respText = await ioResp.transform(utf8.decoder).join();
if (ioResp.statusCode >= 200 && ioResp.statusCode < 300) {
logger.i('reportServer delivered to ${server.name} (${uri.toString()}), status=${ioResp.statusCode}');
} else {
logger.w('reportServer delivery to ${server.name} failed, status=${ioResp.statusCode}, body=$respText');
}
} catch (e, st) {
logger.e("reportServer error $requestUrl", error: e, stackTrace: st);
}
}
}

View File

@@ -50,6 +50,9 @@ class HttpProxyChannelHandler extends ChannelHandler<HttpRequest> {
void exceptionCaught(ChannelContext channelContext, Channel channel, error, {StackTrace? trace}) {
super.exceptionCaught(channelContext, channel, error, trace: trace);
ProxyHelper.exceptionHandler(channelContext, channel, listener, channelContext.currentRequest, error);
for (var interceptor in interceptors) {
interceptor.onError(channelContext.currentRequest, error, trace);
}
}
@override
@@ -277,4 +280,12 @@ class HttpResponseProxyHandler extends ChannelHandler<HttpResponse> {
void channelInactive(ChannelContext channelContext, Channel channel) {
clientChannel.close();
}
@override
void exceptionCaught(ChannelContext channelContext, Channel channel, error, {StackTrace? trace}) {
super.exceptionCaught(channelContext, channel, error, trace: trace);
for (var interceptor in interceptors) {
interceptor.onError(channelContext.currentRequest, error, trace);
}
}
}

View File

@@ -35,6 +35,7 @@ import 'package:proxypin/utils/listenable_list.dart';
import '../../component/model/search_model.dart';
import 'domians.dart';
import 'package:proxypin/ui/desktop/request/report_servers.dart';
/// @author wanghongen
class DesktopRequestListWidget extends StatefulWidget {
@@ -121,28 +122,28 @@ class DesktopRequestListState extends State<DesktopRequestListWidget> with Autom
itemBuilder: (BuildContext context) {
return <PopupMenuEntry>[
CustomPopupMenuItem(
height: 35,
onTap: () => searchKey.currentState?.searchDialog(),
height: 37,
onTap: () => searchKey.currentState?.searchDialog(),
child: IconText(
icon: const Icon(Icons.search, size: 17),
text: localizations.search,
textStyle: const TextStyle(fontSize: 13))),
CustomPopupMenuItem(
height: 35,
height: 37,
onTap: () => export('ProxyPin_${DateTime.now().dateFormat()}.har'),
child: IconText(
icon: const Icon(Icons.share, size: 16),
text: localizations.viewExport,
textStyle: const TextStyle(fontSize: 13))),
CustomPopupMenuItem(
height: 35,
height: 37,
onTap: () => repeatAllRequests(),
child: IconText(
icon: const Icon(Icons.repeat, size: 16),
text: localizations.repeatAllRequests,
textStyle: const TextStyle(fontSize: 13))),
CustomPopupMenuItem(
height: 35,
height: 37,
onTap: () {
sortDesc = !sortDesc;
requestSequenceKey.currentState?.sort(sortDesc);
@@ -152,6 +153,15 @@ class DesktopRequestListState extends State<DesktopRequestListWidget> with Autom
icon: const Icon(Icons.sort, size: 16),
text: sortDesc ? localizations.timeAsc : localizations.timeDesc,
textStyle: const TextStyle(fontSize: 13))),
CustomPopupMenuItem(
height: 37,
onTap: () {
showReportServersDialog(context);
},
child: IconText(
icon: Icon(Icons.cloud_upload_outlined, size: 16),
text: localizations.reportServers,
textStyle: TextStyle(fontSize: 13))),
];
});
}

View File

@@ -0,0 +1,333 @@
/*
* 上报服务器配置页面
*/
import 'package:flutter/material.dart';
import 'package:flutter_toastr/flutter_toastr.dart';
import 'package:proxypin/network/components/manager/report_server_manager.dart';
import 'package:proxypin/ui/component/utils.dart';
import 'package:proxypin/ui/component/widgets.dart';
import '../../../l10n/app_localizations.dart';
// 以弹框的方式展示上报服务器管理
Future<void> showReportServersDialog(BuildContext context) {
return showDialog(
context: context,
barrierDismissible: false,
builder: (ctx) => Dialog(
insetPadding: const EdgeInsets.all(16),
child: SizedBox(
width: 570,
height: 560,
child: const ReportServersPage(),
),
),
);
}
class ReportServersPage extends StatefulWidget {
const ReportServersPage({super.key});
@override
State<ReportServersPage> createState() => _ReportServersPageState();
}
class _ReportServersPageState extends State<ReportServersPage> {
List<ReportServer> _servers = [];
bool _loading = true;
AppLocalizations get localizations => AppLocalizations.of(context)!;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
final manager = await ReportServerManager.instance;
final list = manager.servers;
setState(() {
_servers = List.of(list);
_loading = false;
});
}
InputBorder focusedBorder() {
return OutlineInputBorder(borderSide: BorderSide(color: Theme.of(context).colorScheme.primary, width: 2));
}
// 统一的新增/编辑弹窗
Future<ReportServer?> _showServerDialog({ReportServer? initial}) async {
final nameCtrl = TextEditingController(text: initial?.name ?? '');
final matchUrlCtrl = TextEditingController(text: initial?.matchUrl ?? '');
final serverUrlCtrl = TextEditingController(text: initial?.serverUrl ?? '');
String compression = initial?.compression ?? 'none';
bool enabled = initial?.enabled ?? true;
// 紧凑的 Outline 输入框装饰
InputDecoration dec({String? hint}) => InputDecoration(
hintText: hint,
hintStyle: TextStyle(color: Colors.grey.shade500, fontSize: 14),
contentPadding: const EdgeInsets.symmetric(horizontal: 5, vertical: 12),
errorStyle: const TextStyle(height: 0, fontSize: 0),
focusedBorder: focusedBorder(),
isDense: true,
border: const OutlineInputBorder());
Widget labeled(String label, Widget field, {bool expanded = true}) => Row(
children: [
SizedBox(width: 85, child: Text(label)),
const SizedBox(width: 12),
expanded ? Expanded(child: field) : field,
],
);
final formKey = GlobalKey<FormState>();
final result = await showDialog<ReportServer>(
context: context,
builder: (ctx) {
return AlertDialog(
title: Text(initial == null ? localizations.addReportServer : localizations.editReportServer,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w500)),
content: Form(
key: formKey,
child: SizedBox(
width: 460,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
labeled(
'${localizations.name}: ',
TextField(controller: nameCtrl, decoration: dec(hint: localizations.pleaseEnter)),
),
const SizedBox(height: 12),
labeled(
'${localizations.match} URL: ',
TextFormField(
controller: matchUrlCtrl,
keyboardType: TextInputType.url,
validator: (val) => val?.isNotEmpty == true ? null : "",
decoration: dec(hint: 'https://example.com/api/*')),
),
const SizedBox(height: 12),
labeled(
'${localizations.serverUrl}: ',
TextFormField(
controller: serverUrlCtrl,
keyboardType: TextInputType.url,
validator: (val) => val?.isNotEmpty == true ? null : "",
decoration: dec(hint: 'http://example.com/report')),
),
const SizedBox(height: 12),
labeled(
'${localizations.compression}: ',
expanded: false,
SizedBox(
width: 100,
child: DropdownButtonFormField<String>(
initialValue: compression,
decoration: dec(),
isDense: true,
items: [
DropdownMenuItem(value: 'none', child: Text(localizations.compressionNone)),
DropdownMenuItem(value: 'gzip', child: Text("GZIP")),
],
onChanged: (v) => compression = v ?? 'none',
),
)),
const SizedBox(height: 12),
labeled(
'${localizations.enable}: ',
Align(
alignment: Alignment.centerLeft,
child: SwitchWidget(value: enabled, scale: 0.83, onChanged: (v) => enabled = v),
),
),
],
),
),
)),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, null),
child: Text(localizations.cancel),
),
FilledButton(
onPressed: () {
if (!(formKey.currentState as FormState).validate()) {
FlutterToastr.show("${localizations.serverUrl} ${localizations.cannotBeEmpty}", context, position: FlutterToastr.top);
return;
}
final matchUrl = matchUrlCtrl.text.trim();
var serverUrl = serverUrlCtrl.text.trim();
// 修复此前的前缀判断逻辑:仅当不以 http/https 开头时补全
if (!serverUrl.startsWith('http://') && !serverUrl.startsWith('https://')) {
serverUrl = 'http://$serverUrl';
}
final server = ReportServer(
name: nameCtrl.text.trim(),
matchUrl: matchUrl,
serverUrl: serverUrl,
enabled: enabled,
compression: compression,
);
Navigator.pop(ctx, server);
},
child: Text(localizations.save),
),
],
);
},
);
return result;
}
Future<void> _addServerDialog() async {
final server = await _showServerDialog();
if (server != null) {
final manager = await ReportServerManager.instance;
await manager.add(server);
await _load();
}
}
Future<void> _editServerDialog(int index) async {
final initial = _servers[index];
final server = await _showServerDialog(initial: initial);
if (server != null) {
final manager = await ReportServerManager.instance;
await manager.update(index, server);
setState(() => _servers[index] = server);
}
}
Future<void> _confirmDelete(int index) async {
showConfirmDialog(context, onConfirm: () async {
final manager = await ReportServerManager.instance;
await manager.removeAt(index);
await _load();
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
automaticallyImplyLeading: false,
title: Text(localizations.reportServers),
centerTitle: true,
actions: [
TextButton.icon(
label: Text(localizations.newBuilt),
onPressed: _addServerDialog,
icon: const Icon(Icons.add),
),
const SizedBox(width: 12),
IconButton(
tooltip: localizations.close,
onPressed: () => Navigator.of(context).maybePop(),
icon: const Icon(Icons.close, size: 22),
),
const SizedBox(width: 6),
],
),
body: _loading
? const Center(child: CircularProgressIndicator())
: _servers.isEmpty
? Center(child: Text(localizations.emptyData))
: Padding(
padding: const EdgeInsets.all(8.0),
child: SingleChildScrollView(
scrollDirection: Axis.vertical,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: DataTable(
headingRowHeight: 38,
dataRowMinHeight: 40,
dataRowMaxHeight: 48,
horizontalMargin: 12,
showBottomBorder: true,
dividerThickness: 0.26,
columnSpacing: 8,
columns: [
DataColumn(label: Center(child: Text(localizations.name))),
DataColumn(label: Center(child: Text(localizations.enable))),
DataColumn(label: Center(child: Text('${localizations.match} URL'))),
DataColumn(label: Center(child: Text(localizations.serverUrl))),
DataColumn(label: Center(child: Text(localizations.action))),
],
rows: [
for (final entry in _servers.asMap().entries)
DataRow(cells: [
DataCell(
SizedBox(
width: 65,
child: Text(
entry.value.name.isEmpty ? '-' : entry.value.name,
maxLines: 1,
overflow: TextOverflow.fade,
)),
onTap: () => _editServerDialog(entry.key)),
DataCell(Center(
child: SizedBox(
width: 45,
child: SwitchWidget(
value: entry.value.enabled,
scale: 0.73,
onChanged: (v) async {
final manager = await ReportServerManager.instance;
await manager.toggleEnabled(entry.key, v);
setState(() => _servers[entry.key] = entry.value.copyWith(enabled: v));
},
)))),
DataCell(
SizedBox(
width: 155,
child: Tooltip(
message: entry.value.matchUrl,
child: Text(entry.value.matchUrl, overflow: TextOverflow.ellipsis, maxLines: 1),
),
),
onTap: () => _editServerDialog(entry.key)),
DataCell(
SizedBox(
width: 155,
child: Tooltip(
message: entry.value.serverUrl,
child: Text(entry.value.serverUrl, overflow: TextOverflow.ellipsis, maxLines: 1),
),
),
onTap: () => _editServerDialog(entry.key)),
DataCell(Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: localizations.edit,
onPressed: () => _editServerDialog(entry.key),
icon: const Icon(Icons.edit_outlined, size: 18),
),
IconButton(
tooltip: localizations.delete,
onPressed: () => _confirmDelete(entry.key),
icon: const Icon(Icons.delete_outline, size: 18),
),
],
),
)),
])
],
),
),
),
),
);
}
}

View File

@@ -537,12 +537,13 @@ class _HostsEditDialogState extends State<HostsEditDialog> {
controller: hostController,
validator: (val) => val == null || val.trim().isEmpty ? localizations.cannotBeEmpty : null,
decoration: const InputDecoration(
isDense: true,
hintText: '*.example.com',
hintStyle: TextStyle(color: Colors.grey),
errorStyle: TextStyle(height: 0, fontSize: 0),
border: OutlineInputBorder()))),
]),
const SizedBox(height: 10),
const SizedBox(height: 15),
Row(children: [
SizedBox(width: 80, child: Text(localizations.toAddress)),
Expanded(
@@ -550,6 +551,7 @@ class _HostsEditDialogState extends State<HostsEditDialog> {
controller: toAddressController,
validator: (val) => val == null || val.trim().isEmpty ? localizations.cannotBeEmpty : null,
decoration: const InputDecoration(
isDense: true,
hintText: '202.108.22.5',
errorStyle: TextStyle(height: 0, fontSize: 0),
hintStyle: TextStyle(color: Colors.grey),

View File

@@ -204,13 +204,17 @@ class RequestBlockAddDialog extends StatelessWidget {
TextFormField(
initialValue: item.url,
decoration: const InputDecoration(
labelText: 'URL', hintText: 'https://example.com/*', border: OutlineInputBorder()),
isDense: true,
labelText: 'URL',
hintText: 'https://example.com/*',
border: OutlineInputBorder()),
validator: (val) => val == null || val.trim().isEmpty ? localizations.cannotBeEmpty : null,
onSaved: (val) => item.url = val!.trim()),
const SizedBox(height: 20),
DropdownButtonFormField(
value: item.type,
decoration: InputDecoration(labelText: localizations.type, border: const OutlineInputBorder()),
decoration: InputDecoration(
isDense: true, labelText: localizations.type, border: const OutlineInputBorder()),
items: BlockType.values
.map((e) => DropdownMenuItem(
value: e, child: Text(isCN ? e.label : e.name, style: const TextStyle(fontSize: 14))))

View File

@@ -86,7 +86,7 @@ class _ToolbarState extends State<Toolbar> {
const Padding(padding: EdgeInsets.only(left: 18)),
IconButton(
tooltip: localizations.clear,
icon: const Icon(Icons.cleaning_services_outlined, size: 21),
icon: const Icon(Icons.delete_outline, size: 21),
onPressed: () {
widget.requestListStateKey.currentState?.clean();
}),
@@ -97,7 +97,7 @@ class _ToolbarState extends State<Toolbar> {
const Padding(padding: EdgeInsets.only(left: 18)),
IconButton(
tooltip: localizations.mobileConnect,
icon: const Icon(Icons.phone_iphone, size: 21),
icon: const Icon(Icons.phone_iphone_outlined, size: 21),
onPressed: () async {
final ips = await localIps(readCache: false);
phoneConnect(ips, widget.proxyServer.port);