mirror of
https://github.com/wanghongenpin/proxypin.git
synced 2026-05-30 17:05:49 +08:00
桌面端增加JS脚本
This commit is contained in:
463
lib/ui/desktop/toolbar/setting/script.dart
Normal file
463
lib/ui/desktop/toolbar/setting/script.dart
Normal file
@@ -0,0 +1,463 @@
|
||||
/*
|
||||
* Copyright 2023 the original author or authors.
|
||||
*
|
||||
* 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:desktop_multi_window/desktop_multi_window.dart';
|
||||
import 'package:file_selector/file_selector.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_code_editor/flutter_code_editor.dart';
|
||||
import 'package:flutter_highlight/themes/monokai-sublime.dart';
|
||||
import 'package:flutter_toastr/flutter_toastr.dart';
|
||||
import 'package:highlight/languages/javascript.dart';
|
||||
import 'package:network_proxy/network/util/logger.dart';
|
||||
import 'package:network_proxy/network/util/script_manager.dart';
|
||||
import 'package:network_proxy/ui/component/utils.dart';
|
||||
import 'package:network_proxy/ui/component/widgets.dart';
|
||||
|
||||
bool _refresh = false;
|
||||
|
||||
/// 刷新脚本
|
||||
void _refreshScript() {
|
||||
if (_refresh) {
|
||||
return;
|
||||
}
|
||||
_refresh = true;
|
||||
Future.delayed(const Duration(milliseconds: 1500), () async {
|
||||
_refresh = false;
|
||||
(await ScriptManager.instance).flushConfig();
|
||||
await DesktopMultiWindow.invokeMethod(0, "refreshScript");
|
||||
});
|
||||
}
|
||||
|
||||
class ScriptWidget extends StatefulWidget {
|
||||
final int windowId;
|
||||
|
||||
const ScriptWidget({super.key, required this.windowId});
|
||||
|
||||
@override
|
||||
State<ScriptWidget> createState() => _ScriptWidgetState();
|
||||
}
|
||||
|
||||
class _ScriptWidgetState extends State<ScriptWidget> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
RawKeyboard.instance.addListener(onKeyEvent);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
RawKeyboard.instance.removeListener(onKeyEvent);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void onKeyEvent(RawKeyEvent event) async {
|
||||
if ((event.isKeyPressed(LogicalKeyboardKey.metaLeft) || event.isControlPressed) &&
|
||||
event.isKeyPressed(LogicalKeyboardKey.keyW)) {
|
||||
RawKeyboard.instance.removeListener(onKeyEvent);
|
||||
WindowController.fromWindowId(widget.windowId).close();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Theme.of(context).dialogBackgroundColor,
|
||||
appBar: AppBar(
|
||||
title: const Text("脚本", style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500)),
|
||||
toolbarHeight: 36,
|
||||
centerTitle: true),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.only(left: 15, right: 10),
|
||||
child: futureWidget(
|
||||
ScriptManager.instance,
|
||||
loading: true,
|
||||
(data) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
Row(children: [
|
||||
SizedBox(
|
||||
width: 300,
|
||||
child: SwitchWidget(
|
||||
title: '启用脚本工具',
|
||||
subtitle: "使用 JavaScript 修改请求和响应",
|
||||
value: data.enabled,
|
||||
onChanged: (value) {
|
||||
data.enabled = value;
|
||||
_refreshScript();
|
||||
},
|
||||
)),
|
||||
Expanded(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
const SizedBox(width: 10),
|
||||
FilledButton(
|
||||
style: ElevatedButton.styleFrom(padding: const EdgeInsets.only(left: 20, right: 20)),
|
||||
onPressed: scriptEdit,
|
||||
child: const Text("添加"),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
OutlinedButton(
|
||||
style: ElevatedButton.styleFrom(padding: const EdgeInsets.only(left: 20, right: 20)),
|
||||
onPressed: import,
|
||||
child: const Text("导入"),
|
||||
)
|
||||
],
|
||||
)),
|
||||
const SizedBox(width: 15)
|
||||
]),
|
||||
const SizedBox(height: 5),
|
||||
Container(
|
||||
padding: const EdgeInsets.only(top: 10),
|
||||
constraints: const BoxConstraints(maxHeight: 500, minHeight: 300),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey.withOpacity(0.2)),
|
||||
color: Colors.white,
|
||||
backgroundBlendMode: BlendMode.colorBurn),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 200, padding: const EdgeInsets.only(left: 10), child: const Text("名称")),
|
||||
const SizedBox(width: 50, child: Text("启用", textAlign: TextAlign.center)),
|
||||
const VerticalDivider(),
|
||||
const Expanded(child: Text("URL")),
|
||||
],
|
||||
),
|
||||
const Divider(thickness: 0.5),
|
||||
ScriptList(scripts: data.list, windowId: widget.windowId),
|
||||
]))),
|
||||
]))));
|
||||
}
|
||||
|
||||
//导入js
|
||||
import() async {
|
||||
String? file = Platform.isMacOS
|
||||
? await DesktopMultiWindow.invokeMethod(0, 'openFile', 'json')
|
||||
: await openFile(acceptedTypeGroups: [
|
||||
const XTypeGroup(extensions: ['json'])
|
||||
]).then((it) => it?.path);
|
||||
WindowController.fromWindowId(widget.windowId).show();
|
||||
if (file == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
var json = jsonDecode(await File(file).readAsString());
|
||||
var scriptItem = ScriptItem.fromJson(json);
|
||||
(await ScriptManager.instance).addScript(scriptItem, json['script']);
|
||||
_refreshScript();
|
||||
if (context.mounted) {
|
||||
FlutterToastr.show("导入成功", context);
|
||||
}
|
||||
setState(() {});
|
||||
} catch (e, t) {
|
||||
logger.e('导入失败 $file', error: e, stackTrace: t);
|
||||
if (context.mounted) {
|
||||
FlutterToastr.show("导入失败 $e", context);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 添加脚本
|
||||
scriptEdit() async {
|
||||
showDialog(barrierDismissible: false, context: context, builder: (_) => const ScriptEdit()).then((value) {
|
||||
if (value != null) {
|
||||
setState(() {});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 编辑脚本
|
||||
class ScriptEdit extends StatefulWidget {
|
||||
final ScriptItem? scriptItem;
|
||||
final String? script;
|
||||
|
||||
const ScriptEdit({Key? key, this.scriptItem, this.script}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _ScriptEditState();
|
||||
}
|
||||
|
||||
class _ScriptEditState extends State<ScriptEdit> {
|
||||
static String template = """
|
||||
// 在请求到达服务器之前,调用此函数,您可以在此处修改请求数据
|
||||
// 例如Add/Update/Remove:Queries、Headers、Body
|
||||
async function onRequest(context, request) {
|
||||
console.log(request.url);
|
||||
//URL参数
|
||||
//request.queries["name"] = "value";
|
||||
// 更新或添加新标头
|
||||
//request.headers["X-New-Headers"] = "My-Value";
|
||||
|
||||
//var body = JSON.parse(response.body);
|
||||
//body['key'] = "value";
|
||||
//response.body = JSON.stringify(body);
|
||||
return request;
|
||||
}
|
||||
|
||||
// 在将响应数据发送到客户端之前,调用此函数,您可以在此处修改响应数据
|
||||
async function onResponse(context, request, response) {
|
||||
// 更新或添加新标头
|
||||
// response.headers["Name"] = "Value";
|
||||
// response.statusCode = 200;
|
||||
|
||||
// Update Body
|
||||
//response.body = await fetch('https://www.baidu.com/').then(response => response.text());
|
||||
return response;
|
||||
}
|
||||
""";
|
||||
|
||||
late CodeController script;
|
||||
late TextEditingController nameController;
|
||||
late TextEditingController urlController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
script = CodeController(language: javascript, text: widget.script ?? template);
|
||||
nameController = TextEditingController(text: widget.scriptItem?.name);
|
||||
urlController = TextEditingController(text: widget.scriptItem?.url);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
script.dispose();
|
||||
nameController.dispose();
|
||||
urlController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
GlobalKey formKey = GlobalKey<FormState>();
|
||||
return AlertDialog(
|
||||
scrollable: true,
|
||||
titlePadding: const EdgeInsets.only(left: 15, top: 5, right: 15),
|
||||
title: const Row(children: [
|
||||
Text("编辑脚本", style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500)),
|
||||
SizedBox(width: 10),
|
||||
Tooltip(message: "使用 JavaScript 修改请求和响应", child: Icon(Icons.help_outline, size: 20)),
|
||||
Expanded(child: Align(alignment: Alignment.topRight, child: CloseButton()))
|
||||
]),
|
||||
actionsPadding: const EdgeInsets.only(right: 10, bottom: 10),
|
||||
actions: [
|
||||
ElevatedButton(onPressed: () => Navigator.of(context).pop(), child: const Text("取消")),
|
||||
FilledButton(
|
||||
onPressed: () async {
|
||||
if (!(formKey.currentState as FormState).validate()) {
|
||||
FlutterToastr.show("名称和URL不能为空", context, position: FlutterToastr.top);
|
||||
return;
|
||||
}
|
||||
//新增
|
||||
if (widget.scriptItem == null) {
|
||||
var scriptItem = ScriptItem(true, nameController.text, urlController.text);
|
||||
(await ScriptManager.instance).addScript(scriptItem, script.text);
|
||||
} else {
|
||||
widget.scriptItem?.name = nameController.text;
|
||||
widget.scriptItem?.url = urlController.text;
|
||||
(await ScriptManager.instance).updateScript(widget.scriptItem!, script.text);
|
||||
}
|
||||
|
||||
_refreshScript();
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).maybePop(true);
|
||||
}
|
||||
},
|
||||
child: const Text("保存")),
|
||||
],
|
||||
content: Form(
|
||||
key: formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
textField("名称:", nameController, "请输入名称"),
|
||||
const SizedBox(height: 10),
|
||||
textField("URL:", urlController, "github.com/api/*", keyboardType: TextInputType.url),
|
||||
const SizedBox(height: 10),
|
||||
const Text("脚本:"),
|
||||
const SizedBox(height: 5),
|
||||
SizedBox(
|
||||
width: 850,
|
||||
height: 360,
|
||||
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}) {
|
||||
return Row(children: [
|
||||
SizedBox(width: 50, child: Text(label)),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: controller,
|
||||
validator: (val) => val?.isNotEmpty == true ? null : "",
|
||||
keyboardType: keyboardType,
|
||||
decoration: InputDecoration(
|
||||
hintText: hint,
|
||||
errorStyle: const TextStyle(height: 0, fontSize: 0),
|
||||
focusedBorder: focusedBorder(),
|
||||
isDense: true,
|
||||
constraints: const BoxConstraints(maxHeight: 35),
|
||||
border: const OutlineInputBorder()),
|
||||
))
|
||||
]);
|
||||
}
|
||||
|
||||
InputBorder focusedBorder() {
|
||||
return OutlineInputBorder(borderSide: BorderSide(color: Theme.of(context).primaryColor, width: 2));
|
||||
}
|
||||
}
|
||||
|
||||
/// 脚本列表
|
||||
class ScriptList extends StatefulWidget {
|
||||
final int windowId;
|
||||
final List<ScriptItem> scripts;
|
||||
|
||||
const ScriptList({Key? key, required this.scripts, required this.windowId}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<ScriptList> createState() => _ScriptListState();
|
||||
}
|
||||
|
||||
class _ScriptListState extends State<ScriptList> {
|
||||
Map<int, bool> selected = {};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(children: rows(widget.scripts));
|
||||
}
|
||||
|
||||
List<Widget> rows(List<ScriptItem> list) {
|
||||
var primaryColor = Theme.of(context).primaryColor;
|
||||
|
||||
return List.generate(list.length, (index) {
|
||||
return InkWell(
|
||||
// onTap: () {
|
||||
// selected[index] = !(selected[index] ?? false);
|
||||
// setState(() {});
|
||||
// },
|
||||
highlightColor: Colors.transparent,
|
||||
splashColor: Colors.transparent,
|
||||
hoverColor: primaryColor.withOpacity(0.3),
|
||||
onDoubleTap: () async {
|
||||
String script = await (await ScriptManager.instance).getScript(list[index]);
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
showDialog(
|
||||
barrierDismissible: false,
|
||||
context: context,
|
||||
builder: (_) => ScriptEdit(scriptItem: list[index], script: script)).then((value) {
|
||||
if (value != null) {
|
||||
setState(() {});
|
||||
}
|
||||
});
|
||||
},
|
||||
onSecondaryTapDown: (details) {
|
||||
showContextMenu(context, details.globalPosition, items: [
|
||||
PopupMenuItem(
|
||||
height: 35,
|
||||
child: const Text("编辑"),
|
||||
onTap: () {
|
||||
showDialog(
|
||||
barrierDismissible: false,
|
||||
context: context,
|
||||
builder: (_) => ScriptEdit(scriptItem: list[index])).then((value) {
|
||||
if (value != null) {
|
||||
setState(() {});
|
||||
}
|
||||
});
|
||||
}),
|
||||
PopupMenuItem(height: 35, child: const Text("导出"), onTap: () => export(list[index])),
|
||||
PopupMenuItem(
|
||||
height: 35,
|
||||
child: list[index].enabled ? const Text("禁用") : const Text("启用"),
|
||||
onTap: () {
|
||||
list[index].enabled = !list[index].enabled;
|
||||
_refreshScript();
|
||||
setState(() {});
|
||||
}),
|
||||
const PopupMenuDivider(),
|
||||
PopupMenuItem(
|
||||
height: 35,
|
||||
child: const Text("删除"),
|
||||
onTap: () async {
|
||||
(await ScriptManager.instance).removeScript(index);
|
||||
setState(() {});
|
||||
if (context.mounted) FlutterToastr.show('删除成功', context);
|
||||
}),
|
||||
]);
|
||||
},
|
||||
child: Container(
|
||||
color: selected[index] == true
|
||||
? primaryColor.withOpacity(0.8)
|
||||
: index.isEven
|
||||
? Colors.grey.withOpacity(0.1)
|
||||
: null,
|
||||
height: 30,
|
||||
padding: const EdgeInsets.all(5),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(width: 200, child: Text(list[index].name!, style: const TextStyle(fontSize: 13))),
|
||||
SizedBox(
|
||||
width: 40,
|
||||
child: Transform.scale(
|
||||
scale: 0.65,
|
||||
child: SwitchWidget(
|
||||
value: list[index].enabled,
|
||||
onChanged: (val) {
|
||||
list[index].enabled = val;
|
||||
_refreshScript();
|
||||
}))),
|
||||
const SizedBox(width: 20),
|
||||
Expanded(child: Text(list[index].url, style: const TextStyle(fontSize: 13))),
|
||||
],
|
||||
)));
|
||||
});
|
||||
}
|
||||
|
||||
//导出js
|
||||
export(ScriptItem item) async {
|
||||
//文件名称
|
||||
String fileName = '${item.name}.json';
|
||||
String? saveLocation = Platform.isMacOS
|
||||
? await DesktopMultiWindow.invokeMethod(0, 'getSaveLocation', fileName)
|
||||
: (await getSaveLocation(suggestedName: fileName))?.path;
|
||||
WindowController.fromWindowId(widget.windowId).show();
|
||||
if (saveLocation == null) {
|
||||
return;
|
||||
}
|
||||
var json = item.toJson();
|
||||
json.remove("scriptPath");
|
||||
json['script'] = await (await ScriptManager.instance).getScript(item);
|
||||
final XFile xFile = XFile.fromData(utf8.encode(jsonEncode(json)), mimeType: 'json');
|
||||
await xFile.saveTo(saveLocation);
|
||||
if (context.mounted) FlutterToastr.show("导出成功", context);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter_toastr/flutter_toastr.dart';
|
||||
import 'package:network_proxy/network/bin/configuration.dart';
|
||||
import 'package:network_proxy/network/bin/server.dart';
|
||||
import 'package:network_proxy/network/util/system_proxy.dart';
|
||||
import 'package:network_proxy/ui/component/multi_window.dart';
|
||||
import 'package:network_proxy/ui/desktop/toolbar/setting/external_proxy.dart';
|
||||
import 'package:network_proxy/ui/desktop/toolbar/setting/request_rewrite.dart';
|
||||
import 'package:network_proxy/ui/desktop/toolbar/setting/theme.dart';
|
||||
@@ -59,9 +60,10 @@ class _SettingState extends State<Setting> {
|
||||
},
|
||||
menuChildren: [
|
||||
_ProxyMenu(proxyServer: widget.proxyServer),
|
||||
const ThemeSetting(),
|
||||
item("域名过滤", onPressed: hostFilter),
|
||||
item("请求重写", onPressed: requestRewrite),
|
||||
const ThemeSetting(),
|
||||
item("脚本", onPressed: () => openScriptWindow()),
|
||||
item("外部代理设置", onPressed: setExternalProxy),
|
||||
item("Github", onPressed: () => launchUrl(Uri.parse("https://github.com/wanghongenpin/network_proxy_flutter"))),
|
||||
],
|
||||
@@ -181,7 +183,7 @@ class _ProxyMenuState extends State<_ProxyMenu> {
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text("代理忽略域名"),
|
||||
const Text("代理忽略域名", style: TextStyle(fontSize: 14)),
|
||||
const SizedBox(height: 3),
|
||||
Text("多个使用;分割", style: TextStyle(fontSize: 11, color: Colors.grey.shade600)),
|
||||
],
|
||||
@@ -210,7 +212,7 @@ class _ProxyMenuState extends State<_ProxyMenu> {
|
||||
minLines: 1)),
|
||||
const SizedBox(height: 10),
|
||||
],
|
||||
child: const Padding(padding: EdgeInsets.only(left: 10), child: Text("代理")),
|
||||
child: const Padding(padding: EdgeInsets.only(left: 10), child: Text("代理",style: TextStyle(fontSize: 14))),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ class ThemeSetting extends StatelessWidget {
|
||||
themeNotifier.value = themeNotifier.value.copy(mode: ThemeMode.light);
|
||||
}),
|
||||
],
|
||||
child: const Padding(padding: EdgeInsets.only(left: 10), child: Text("主题")),
|
||||
child: const Padding(padding: EdgeInsets.only(left: 10), child: Text("主题",style: TextStyle(fontSize: 14))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user