The script supports multiple URL matching

This commit is contained in:
wanghongenpin
2025-09-23 22:43:28 +08:00
parent 9869c00afc
commit f37330a517
9 changed files with 261 additions and 115 deletions

View File

@@ -268,7 +268,7 @@ class Client extends Network {
// host = host.substring(1, host.length - 1);
// }
logger.d('Connecting to $host:${hostAndPort.port}');
// logger.d('Connecting to $host:${hostAndPort.port}');
return Socket.connect(host, hostAndPort.port, timeout: timeout).then((socket) {
if (socket.address.type != InternetAddressType.unix) {
socket.setOption(SocketOption.tcpNoDelay, true);

View File

@@ -36,8 +36,6 @@ class ScriptManager {
// e.g. Add/Update/RemoveQueries、Headers、Body
async function onRequest(context, request) {
console.log(request.url);
//URL queries
//request.queries["name"] = "value";
//Update or add Header
//request.headers["X-New-Headers"] = "My-Value";
@@ -312,28 +310,48 @@ class LogInfo {
class ScriptItem {
bool enabled = true;
String? name;
String url;
List<String> urls;
String? scriptPath;
RegExp? urlReg;
List<RegExp?>? urlRegs;
ScriptItem(this.enabled, this.name, this.url, {this.scriptPath});
ScriptItem(this.enabled, this.name, dynamic urls, {this.scriptPath})
: urls = urls is String
? (urls.contains(',') ? urls.split(',').map((e) => e.trim()).toList() : [urls])
: (urls is List<String> ? urls : <String>[]);
//匹配url
// 匹配url,任意一个规则匹配即可
bool match(String url) {
urlReg ??= RegExp(this.url.replaceAll("*", ".*"));
return urlReg!.hasMatch(url);
urlRegs ??= urls.map((u) => RegExp(u.replaceAll("*", ".*"))).toList();
for (final reg in urlRegs!) {
if (reg!.hasMatch(url)) return true;
}
return false;
}
factory ScriptItem.fromJson(Map<dynamic, dynamic> json) {
return ScriptItem(json['enabled'], json['name'], json['url'], scriptPath: json['scriptPath']);
final urlField = json['url'];
List<String> urls;
if (urlField is List) {
urls = urlField.cast<String>();
} else if (urlField is String) {
urls = urlField.contains(',') ? urlField.split(',').map((e) => e.trim()).toList() : [urlField];
} else {
urls = <String>[];
}
return ScriptItem(json['enabled'], json['name'], urls, scriptPath: json['scriptPath']);
}
Map<String, dynamic> toJson() {
return {'enabled': enabled, 'name': name, 'url': url, 'scriptPath': scriptPath};
return {
'enabled': enabled,
'name': name,
'url': urls.length == 1 ? urls[0] : urls,
'scriptPath': scriptPath
};
}
@override
String toString() {
return 'ScriptItem{enabled: $enabled, name: $name, url: $url, scriptPath: $scriptPath}';
return 'ScriptItem{enabled: $enabled, name: $name, url: $urls, scriptPath: $scriptPath}';
}
}

View File

@@ -87,8 +87,8 @@ class HttpProxyChannelHandler extends ChannelHandler<HttpRequest> {
//实现抓包代理转发
if (httpRequest.method != HttpMethod.connect) {
log.d(
"[${channel.id}] streamId:${httpRequest.streamId ?? ''} ${httpRequest.protocolVersion} ${httpRequest.method.name} ${httpRequest.requestUrl}");
// log.d(
// "[${channel.id}] streamId:${httpRequest.streamId ?? ''} ${httpRequest.protocolVersion} ${httpRequest.method.name} ${httpRequest.requestUrl}");
if (HostFilter.filter(httpRequest.hostAndPort?.host)) {
await remoteChannel.write(channelContext, httpRequest);
return;

View File

@@ -44,10 +44,11 @@ class ProxyHelper {
'blacklist': HostFilter.blacklist.toJson(),
'scripts': await ScriptManager.instance.then((script) {
var list = script.list.map((e) async {
return {'name': e.name, 'enabled': e.enabled, 'url': e.url, 'script': await script.getScript(e)};
return {'name': e.name, 'enabled': e.enabled, 'url': e.urls, 'script': await script.getScript(e)};
});
return Future.wait(list);
}),
};
response.body = utf8.encode(json.encode(body));
channel.writeAndClose(channelContext, response);

View File

@@ -215,7 +215,7 @@ class _RequestWidgetState extends State<RequestWidget> {
onClick: (_) async {
var scriptManager = await ScriptManager.instance;
var url = widget.request.domainPath;
var scriptItem = (scriptManager).list.firstWhereOrNull((it) => it.url == url);
var scriptItem = (scriptManager).list.firstWhereOrNull((it) => it.urls.contains(url));
String? script = scriptItem == null ? null : await scriptManager.getScript(scriptItem);
if (!mounted) return;

View File

@@ -335,7 +335,7 @@ class ScriptEdit extends StatefulWidget {
class _ScriptEditState extends State<ScriptEdit> {
late CodeController script;
late TextEditingController nameController;
late TextEditingController urlController;
late List<TextEditingController> urlControllers;
AppLocalizations get localizations => AppLocalizations.of(context)!;
@@ -344,14 +344,18 @@ class _ScriptEditState extends State<ScriptEdit> {
super.initState();
script = CodeController(language: javascript, text: widget.script ?? ScriptManager.template);
nameController = TextEditingController(text: widget.scriptItem?.name ?? widget.title);
urlController = TextEditingController(text: widget.scriptItem?.url ?? widget.url);
final urls = widget.scriptItem?.urls ?? (widget.url != null && widget.url!.isNotEmpty ? [widget.url!] : []);
urlControllers =
urls.isNotEmpty ? urls.map((u) => TextEditingController(text: u)).toList() : [TextEditingController()];
}
@override
void dispose() {
script.dispose();
nameController.dispose();
urlController.dispose();
for (final c in urlControllers) {
c.dispose();
}
super.dispose();
}
@@ -361,72 +365,130 @@ class _ScriptEditState extends State<ScriptEdit> {
bool isCN = Localizations.localeOf(context) == const Locale.fromSubtags(languageCode: 'zh');
return AlertDialog(
scrollable: true,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10.0)),
titlePadding: const EdgeInsets.only(left: 15, top: 5, right: 15),
title: Row(children: [
Text(localizations.scriptEdit, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500)),
const SizedBox(width: 10),
Text.rich(TextSpan(
text: localizations.useGuide,
style: const TextStyle(color: Colors.blue, fontSize: 14),
recognizer: TapGestureRecognizer()
..onTap = () => DesktopMultiWindow.invokeMethod(
0,
"launchUrl",
isCN
? 'https://gitee.com/wanghongenpin/proxypin/wikis/%E8%84%9A%E6%9C%AC'
: 'https://github.com/wanghongenpin/proxypin/wiki/Script'))),
const Expanded(child: Align(alignment: Alignment.topRight, child: CloseButton()))
]),
actionsPadding: const EdgeInsets.only(right: 10, bottom: 10),
actions: [
ElevatedButton(onPressed: () => Navigator.of(context).pop(), child: Text(localizations.cancel)),
FilledButton(
onPressed: () async {
if (!(formKey.currentState as FormState).validate()) {
FlutterToastr.show("${localizations.name} URL ${localizations.cannotBeEmpty}", context,
position: FlutterToastr.top);
return;
}
//新增
if (widget.scriptItem == null) {
var scriptItem = ScriptItem(true, nameController.text, urlController.text);
await (await ScriptManager.instance).addScript(scriptItem, script.text);
} else {
widget.scriptItem?.name = nameController.text;
widget.scriptItem?.url = urlController.text;
widget.scriptItem?.urlReg = null;
(await ScriptManager.instance).updateScript(widget.scriptItem!, script.text);
}
_refreshScript();
if (context.mounted) {
Navigator.of(context).maybePop(true);
}
},
child: Text(localizations.save)),
],
content: Form(
key: formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
textField("${localizations.name}:", nameController, localizations.pleaseEnter),
const SizedBox(height: 10),
textField("URL:", urlController, "github.com/api/*", keyboardType: TextInputType.url),
const SizedBox(height: 10),
Text("${localizations.script}:"),
const SizedBox(height: 5),
SizedBox(
width: 850,
height: 380,
child: CodeTheme(
data: CodeThemeData(styles: monokaiSublimeTheme),
child: SingleChildScrollView(
child: CodeField(textStyle: const TextStyle(fontSize: 13), controller: script))))
],
)));
scrollable: true,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10.0)),
titlePadding: const EdgeInsets.only(left: 15, top: 5, right: 15),
title: Row(children: [
Text(localizations.scriptEdit, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500)),
const SizedBox(width: 10),
Text.rich(TextSpan(
text: localizations.useGuide,
style: const TextStyle(color: Colors.blue, fontSize: 14),
recognizer: TapGestureRecognizer()
..onTap = () => DesktopMultiWindow.invokeMethod(
0,
"launchUrl",
isCN
? 'https://gitee.com/wanghongenpin/proxypin/wikis/%E8%84%9A%E6%9C%AC'
: 'https://github.com/wanghongenpin/proxypin/wiki/Script'))),
const Expanded(child: Align(alignment: Alignment.topRight, child: CloseButton()))
]),
actionsPadding: const EdgeInsets.only(right: 10, bottom: 10),
actions: [
ElevatedButton(onPressed: () => Navigator.of(context).pop(), child: Text(localizations.cancel)),
FilledButton(
onPressed: () async {
if (!(formKey.currentState as FormState).validate()) {
FlutterToastr.show("${localizations.name} URL ${localizations.cannotBeEmpty}", context,
position: FlutterToastr.top);
return;
}
final urls = urlControllers.map((c) => c.text.trim()).where((u) => u.isNotEmpty).toSet().toList();
if (urls.isEmpty) {
FlutterToastr.show("URL ${localizations.cannotBeEmpty}", context, position: FlutterToastr.top);
return;
}
if (widget.scriptItem == null) {
var scriptItem = ScriptItem(true, nameController.text, urls);
await (await ScriptManager.instance).addScript(scriptItem, script.text);
} else {
widget.scriptItem?.name = nameController.text;
widget.scriptItem?.urls = urls;
widget.scriptItem?.urlRegs = null;
(await ScriptManager.instance).updateScript(widget.scriptItem!, script.text);
}
_refreshScript();
if (context.mounted) {
Navigator.of(context).maybePop(true);
}
},
child: Text(localizations.save)),
],
content: Form(
key: formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
textField("${localizations.name}:", nameController, localizations.pleaseEnter),
const SizedBox(height: 3),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Text("URL(s):"),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.add_circle_outline, size: 20),
tooltip: localizations.add,
onPressed: () {
setState(() {
urlControllers.add(TextEditingController());
});
},
),
],
),
...List.generate(
urlControllers.length,
(i) => Row(
children: [
Expanded(
child: TextFormField(
controller: urlControllers[i],
validator: (val) => val?.isNotEmpty == true ? null : "",
keyboardType: TextInputType.url,
decoration: InputDecoration(
hintText: "github.com/api/*",
hintStyle: const TextStyle(fontSize: 14, color: Colors.grey),
contentPadding: const EdgeInsets.all(10),
errorStyle: const TextStyle(height: 0, fontSize: 0),
focusedBorder: focusedBorder(),
isDense: true,
border: const OutlineInputBorder(),
),
),
),
if (urlControllers.length > 1)
IconButton(
icon: const Icon(Icons.remove_circle_outline, color: Colors.red),
tooltip: localizations.delete,
onPressed: () {
setState(() {
urlControllers[i].dispose();
urlControllers.removeAt(i);
});
},
),
],
)),
],
),
const SizedBox(height: 10),
Text(
"${localizations.script}:",
),
const SizedBox(height: 5),
SizedBox(
width: 850,
height: 380,
child: CodeTheme(
data: CodeThemeData(styles: monokaiSublimeTheme),
child: SingleChildScrollView(
child: CodeField(textStyle: const TextStyle(fontSize: 13), controller: script))))
],
)),
);
}
Widget textField(String label, TextEditingController controller, String hint, {TextInputType? keyboardType}) {
@@ -439,6 +501,7 @@ class _ScriptEditState extends State<ScriptEdit> {
keyboardType: keyboardType,
decoration: InputDecoration(
hintText: hint,
hintStyle: const TextStyle(fontSize: 14, color: Colors.grey),
contentPadding: const EdgeInsets.all(10),
errorStyle: const TextStyle(height: 0, fontSize: 0),
focusedBorder: focusedBorder(),
@@ -573,13 +636,13 @@ class _ScriptListState extends State<ScriptList> {
_refreshScript();
}))),
const SizedBox(width: 20),
Expanded(child: Text(list[index].url, style: const TextStyle(fontSize: 13))),
Expanded(child: Text(list[index].urls.join(', '), style: const TextStyle(fontSize: 13))),
],
)));
});
}
showGlobalMenu(Offset offset) {
void showGlobalMenu(Offset offset) {
showContextMenu(context, offset, items: [
PopupMenuItem(height: 35, child: Text(localizations.newBuilt), onTap: () => showEdit()),
PopupMenuItem(height: 35, child: Text(localizations.export), onTap: () => export(selected.toList())),

View File

@@ -260,12 +260,12 @@ class _FavoriteItemState extends State<_FavoriteItem> {
Navigator.maybePop(context);
var scriptManager = await ScriptManager.instance;
var url = request.domainPath;
var scriptItem = (scriptManager).list.firstWhereOrNull((it) => it.url == url);
var scriptItem = (scriptManager).list.firstWhereOrNull((it) => it.urls.contains(url));
String? script = scriptItem == null ? null : await scriptManager.getScript(scriptItem);
var pageRoute = MaterialPageRoute(
builder: (context) =>
ScriptEdit(scriptItem: scriptItem, script: script, url: scriptItem?.url ?? url));
ScriptEdit(scriptItem: scriptItem, script: script, urls: scriptItem?.urls ?? [url]));
if (mounted) Navigator.push(context, pageRoute);
},
label: localizations.script,

View File

@@ -274,15 +274,15 @@ class RequestRowState extends State<RequestRow> {
var scriptManager = await ScriptManager.instance;
var url = request.domainPath;
var scriptItem = scriptManager.list.firstWhereOrNull((it) => it.url == url);
var scriptItem = scriptManager.list.firstWhereOrNull((it) => it.urls.contains(url));
String? script = scriptItem == null ? null : await scriptManager.getScript(scriptItem);
var pageRoute = MaterialPageRoute(
builder: (context) => ScriptEdit(
scriptItem: scriptItem,
script: script,
url: scriptItem?.url ?? url,
title: request.hostAndPort?.host));
scriptItem: scriptItem,
script: script,
urls: scriptItem?.urls ?? [url],
title: request.hostAndPort?.host));
Navigator.push(getContext(), pageRoute);
},

View File

@@ -112,13 +112,13 @@ class _MobileScriptState extends State<MobileScript> {
]))));
}
consoleLog() {
void consoleLog() {
// FloatingWindowManager().show(context);
Navigator.of(context).push(MaterialPageRoute(builder: (context) => const ScriptConsoleLog()));
}
//导入js
import() async {
Future<void> import() async {
FilePickerResult? result = await FilePicker.platform.pickFiles(type: FileType.any);
if (result == null || result.files.isEmpty) {
return;
@@ -377,10 +377,10 @@ class _ScriptLogSmallWindowState extends State<ScriptLogSmallWindow> {
class ScriptEdit extends StatefulWidget {
final ScriptItem? scriptItem;
final String? script;
final String? url;
final List<String>? urls;
final String? title;
const ScriptEdit({super.key, this.scriptItem, this.script, this.url, this.title});
const ScriptEdit({super.key, this.scriptItem, this.script, this.urls, this.title});
@override
State<StatefulWidget> createState() => _ScriptEditState();
@@ -389,23 +389,28 @@ class ScriptEdit extends StatefulWidget {
class _ScriptEditState extends State<ScriptEdit> {
late CodeController script;
late TextEditingController nameController;
late TextEditingController urlController;
late List<TextEditingController> urlControllers;
AppLocalizations get localizations => AppLocalizations.of(context)!;
@override
void initState() {
super.initState();
final urls =
widget.scriptItem?.urls ?? (widget.urls != null && widget.urls!.isNotEmpty ? widget.urls! : <String>[]);
urlControllers =
urls.isNotEmpty ? urls.map((u) => TextEditingController(text: u)).toList() : [TextEditingController()];
script = CodeController(language: javascript, text: widget.script ?? ScriptManager.template);
nameController = TextEditingController(text: widget.scriptItem?.name ?? widget.title);
urlController = TextEditingController(text: widget.scriptItem?.url ?? widget.url);
}
@override
void dispose() {
for (final c in urlControllers) {
c.dispose();
}
script.dispose();
nameController.dispose();
urlController.dispose();
super.dispose();
}
@@ -437,15 +442,20 @@ class _ScriptEditState extends State<ScriptEdit> {
position: FlutterToastr.top);
return;
}
//新增
// 收集所有非空、去重的 url
final urls = urlControllers.map((c) => c.text.trim()).where((u) => u.isNotEmpty).toSet().toList();
if (urls.isEmpty) {
FlutterToastr.show("URL ${localizations.cannotBeEmpty}", context, position: FlutterToastr.top);
return;
}
var scriptManager = await ScriptManager.instance;
if (widget.scriptItem == null) {
var scriptItem = ScriptItem(true, nameController.text, urlController.text);
var scriptItem = ScriptItem(true, nameController.text, urls);
await scriptManager.addScript(scriptItem, script.text);
} else {
widget.scriptItem?.name = nameController.text;
widget.scriptItem?.url = urlController.text;
widget.scriptItem?.urlReg = null;
widget.scriptItem?.urls = urls;
widget.scriptItem?.urlRegs = null;
await scriptManager.updateScript(widget.scriptItem!, script.text);
}
@@ -464,19 +474,72 @@ class _ScriptEditState extends State<ScriptEdit> {
Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: textField("${localizations.name}:", nameController, localizations.pleaseEnter)),
const SizedBox(height: 10),
const SizedBox(height: 3),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: textField("URL:", urlController, "github.com/api/*", keyboardType: TextInputType.url)),
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Text("URL(s):"),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.add_circle_outline, size: 20),
tooltip: localizations.add,
onPressed: () {
setState(() {
urlControllers.add(TextEditingController());
});
},
),
],
),
...List.generate(
urlControllers.length,
(i) => Row(
children: [
Expanded(
child: TextFormField(
controller: urlControllers[i],
validator: (val) => val?.isNotEmpty == true ? null : "",
keyboardType: TextInputType.url,
decoration: InputDecoration(
hintText: "github.com/api/*",
contentPadding: const EdgeInsets.all(10),
errorStyle: const TextStyle(height: 0, fontSize: 0),
focusedBorder: focusedBorder(),
isDense: true,
border: const OutlineInputBorder(),
),
),
),
if (urlControllers.length > 1)
IconButton(
icon: const Icon(Icons.remove_circle_outline, color: Colors.red),
tooltip: localizations.delete,
onPressed: () {
setState(() {
urlControllers[i].dispose();
urlControllers.removeAt(i);
});
},
),
],
)),
],
),
),
const SizedBox(height: 10),
Row(children: [
SizedBox(width: 10),
Text("${localizations.script}:"),
Text(
"${localizations.script}:",
),
SizedBox(width: 10),
IconButton(
tooltip: localizations.copy,
onPressed: () {
//复制
Clipboard.setData(ClipboardData(text: script.text));
FlutterToastr.show(localizations.copied, context, position: FlutterToastr.top);
},
@@ -559,7 +622,7 @@ class _ScriptListState extends State<ScriptList> {
]))));
}
globalMenu() {
Stack globalMenu() {
return Stack(children: [
Container(
height: 50,
@@ -644,14 +707,15 @@ class _ScriptListState extends State<ScriptList> {
_refreshScript();
}))),
const SizedBox(width: 10),
Expanded(child: Text(list[index].url.fixAutoLines(), style: const TextStyle(fontSize: 13))),
Expanded(
child: Text(list[index].urls.join(', ').fixAutoLines(), style: const TextStyle(fontSize: 13))),
],
)));
});
}
//点击菜单
showMenus(int index) {
void showMenus(int index) {
setState(() {
selected.add(index);
});
@@ -750,7 +814,7 @@ class _ScriptListState extends State<ScriptList> {
Share.shareXFiles([file], fileNameOverrides: [fileName], sharePositionOrigin: box?.paintBounds);
}
enableStatus(bool enable) {
void enableStatus(bool enable) {
for (var idx in selected) {
widget.scripts[idx].enabled = enable;
}
@@ -758,7 +822,7 @@ class _ScriptListState extends State<ScriptList> {
_refreshScript();
}
removeScripts(List<int> indexes) async {
Future<void> removeScripts(List<int> indexes) async {
if (indexes.isEmpty) return;
showConfirmDialog(context, content: localizations.confirmContent, onConfirm: () async {
var scriptManager = await ScriptManager.instance;