From f8119b2dca7b17be0b76e73c86262dab7a968451 Mon Sep 17 00:00:00 2001 From: wanghongenpin Date: Sun, 24 Mar 2024 17:44:44 +0800 Subject: [PATCH] mobile script console log --- lib/network/components/script_manager.dart | 63 ++++- lib/ui/desktop/toolbar/setting/script.dart | 8 - lib/ui/mobile/menu.dart | 24 +- lib/ui/mobile/mobile.dart | 2 +- lib/ui/mobile/setting/script.dart | 302 ++++++++++++++++++--- lib/ui/mobile/widgets/floating_window.dart | 123 +++++++++ lib/utils/lang.dart | 27 ++ 7 files changed, 477 insertions(+), 72 deletions(-) create mode 100644 lib/ui/mobile/widgets/floating_window.dart diff --git a/lib/network/components/script_manager.dart b/lib/network/components/script_manager.dart index 83acc1a..487d131 100644 --- a/lib/network/components/script_manager.dart +++ b/lib/network/components/script_manager.dart @@ -49,7 +49,7 @@ async function onResponse(context, request, response) { static JavascriptRuntime flutterJs = getJavascriptRuntime(); - static final List _windowIds = []; + static final List _logHandlers = []; ScriptManager._(); @@ -58,6 +58,7 @@ async function onResponse(context, request, response) { if (_instance == null) { _instance = ScriptManager._(); await _instance?.reloadScript(); + // register channel callback final channelCallbacks = JavascriptRuntime.channelFunctionsRegistered[flutterJs.getEngineInstanceId()]; channelCallbacks!["ConsoleLog"] = _instance!.consoleLog; @@ -67,20 +68,36 @@ async function onResponse(context, request, response) { } static void registerConsoleLog(int fromWindowId) { - if (!_windowIds.contains(fromWindowId)) _windowIds.add(fromWindowId); + LogHandler logHandler = LogHandler( + channelId: fromWindowId, + handle: (logInfo) { + DesktopMultiWindow.invokeMethod(fromWindowId, "consoleLog", logInfo.toJson()).onError((e, t) { + logger.e("consoleLog error: $e"); + removeLogHandler(fromWindowId); + }); + }); + registerLogHandler(logHandler); + } + + static void registerLogHandler(LogHandler logHandler) { + if (!_logHandlers.any((it) => it.channelId == logHandler.channelId)) _logHandlers.add(logHandler); + } + + static void removeLogHandler(int channelId) { + _logHandlers.removeWhere((element) => channelId == element.channelId); } dynamic consoleLog(dynamic args) async { + if (_logHandlers.isEmpty) { + return; + } + var level = args.removeAt(0); String output = args.join(' '); if (level == 'info') level = 'warn'; - - for (int i = 0; i < _windowIds.length; i++) { - var winId = _windowIds.elementAt(i); - DesktopMultiWindow.invokeMethod(winId, "consoleLog", {"level": level, "output": output}).onError((e, t) { - logger.e("consoleLog error: $e"); - _windowIds.remove(winId); - }); + LogInfo logInfo = LogInfo(level, output); + for (int i = 0; i < _logHandlers.length; i++) { + _logHandlers[i].handle.call(logInfo); } } @@ -314,6 +331,34 @@ async function onResponse(context, request, response) { } } +class LogHandler { + final int channelId; + final Function(LogInfo logInfo) handle; + + LogHandler({required this.channelId, required this.handle}); +} + +class LogInfo { + final DateTime time; + final String level; + final String output; + + LogInfo(this.level, this.output, {DateTime? time}) : time = time ?? DateTime.now(); + + factory LogInfo.fromJson(Map json) { + return LogInfo(json['level'], json['output'], time: DateTime.fromMillisecondsSinceEpoch(json['time'])); + } + + Map toJson() { + return {'time': time.millisecondsSinceEpoch, 'level': level, 'output': output}; + } + + @override + String toString() { + return '{time: $time, level: $level, output: $output}'; + } +} + class ScriptItem { bool enabled = true; String? name; diff --git a/lib/ui/desktop/toolbar/setting/script.dart b/lib/ui/desktop/toolbar/setting/script.dart index 550655a..e677838 100644 --- a/lib/ui/desktop/toolbar/setting/script.dart +++ b/lib/ui/desktop/toolbar/setting/script.dart @@ -200,14 +200,6 @@ class ScriptConsoleWidget extends StatefulWidget { State createState() => _ScriptConsoleState(); } -class LogInfo { - final DateTime time = DateTime.now(); - final String level; - final String output; - - LogInfo(this.level, this.output); -} - class _ScriptConsoleState extends State { final List logs = []; final ScrollController _scrollController = ScrollController(); diff --git a/lib/ui/mobile/menu.dart b/lib/ui/mobile/menu.dart index 28bb262..6ef9839 100644 --- a/lib/ui/mobile/menu.dart +++ b/lib/ui/mobile/menu.dart @@ -289,11 +289,7 @@ class MoreMenu extends StatelessWidget { title: Text(localizations.httpsProxy), leading: Icon(Icons.https_outlined, color: proxyServer.enableSsl ? null : Colors.red), onTap: () { - Navigator.of(context).push( - MaterialPageRoute(builder: (BuildContext context) { - return MobileSslWidget(proxyServer: proxyServer); - }), - ); + navigator(context, MobileSslWidget(proxyServer: proxyServer)); })), PopupMenuItem( height: 32, @@ -302,6 +298,7 @@ class MoreMenu extends StatelessWidget { leading: const Icon(Icons.qr_code_scanner_outlined), title: Text(localizations.connectRemote), onTap: () { + Navigator.maybePop(context); connectRemote(context); }, )), @@ -312,6 +309,7 @@ class MoreMenu extends StatelessWidget { leading: const Icon(Icons.phone_iphone_outlined), title: Text(localizations.myQRCode), onTap: () async { + Navigator.maybePop(context); var ip = await localIp(); if (context.mounted) { connectQrCode(context, ip, proxyServer.port); @@ -326,11 +324,7 @@ class MoreMenu extends StatelessWidget { leading: const Icon(Icons.highlight_outlined), title: Text(localizations.highlight), onTap: () { - Navigator.of(context).push( - MaterialPageRoute(builder: (BuildContext context) { - return const KeywordHighlight(); - }), - ); + navigator(context, const KeywordHighlight()); }, )), PopupMenuItem( @@ -340,6 +334,7 @@ class MoreMenu extends StatelessWidget { leading: const Icon(Icons.share_outlined), title: Text(localizations.viewExport), onTap: () async { + Navigator.maybePop(context); var name = formatDate(DateTime.now(), [m, '-', d, ' ', HH, ':', nn, ':', ss]); MobileHomeState.requestStateKey.currentState?.export('ProxyPin$name'); }, @@ -349,6 +344,15 @@ class MoreMenu extends StatelessWidget { ); } + void navigator(BuildContext context, Widget widget) async { + await Navigator.maybePop(context); + if (context.mounted) { + Navigator.of(context).push( + MaterialPageRoute(builder: (BuildContext context) => widget), + ); + } + } + ///扫码连接 connectRemote(BuildContext context) async { AppLocalizations localizations = AppLocalizations.of(context)!; diff --git a/lib/ui/mobile/mobile.dart b/lib/ui/mobile/mobile.dart index bb23480..73eb57f 100644 --- a/lib/ui/mobile/mobile.dart +++ b/lib/ui/mobile/mobile.dart @@ -300,7 +300,7 @@ class MobileHomeState extends State implements EventListener, Li retry++; } - if (retry > 3) { + if (retry > 5) { timer.cancel(); desktop.value = RemoteModel(connect: false); if (context.mounted) { diff --git a/lib/ui/mobile/setting/script.dart b/lib/ui/mobile/setting/script.dart index 2bfe4c6..c2d8491 100644 --- a/lib/ui/mobile/setting/script.dart +++ b/lib/ui/mobile/setting/script.dart @@ -4,6 +4,7 @@ import 'package:file_selector/file_selector.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_code_editor/flutter_code_editor.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_highlight/themes/monokai-sublime.dart'; import 'package:flutter_toastr/flutter_toastr.dart'; import 'package:highlight/languages/javascript.dart'; @@ -11,9 +12,10 @@ import 'package:network_proxy/network/components/script_manager.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/ui/mobile/widgets/floating_window.dart'; +import 'package:network_proxy/utils/lang.dart'; import 'package:share_plus/share_plus.dart'; import 'package:url_launcher/url_launcher.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; /// @author wanghongen /// 2023/10/19 @@ -51,8 +53,7 @@ class _MobileScriptState extends State { child: futureWidget( ScriptManager.instance, loading: true, - (data) => - Column( + (data) => Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start, children: [ @@ -84,6 +85,11 @@ class _MobileScriptState extends State { label: Text(localizations.import), ), const SizedBox(width: 10), + FilledButton.icon( + icon: const Icon(Icons.terminal, size: 18), + onPressed: consoleLog, + label: Text(localizations.logger), + ), ], ), const SizedBox(height: 5), @@ -91,6 +97,11 @@ class _MobileScriptState extends State { ])))); } + consoleLog() { + // FloatingWindowManager().show(context); + Navigator.of(context).push(MaterialPageRoute(builder: (context) => const ScriptConsoleLog())); + } + //导入js import() async { final XFile? file = await openFile(); @@ -135,6 +146,218 @@ class _MobileScriptState extends State { } } +///控制台日志 +class ScriptConsoleLog extends StatefulWidget { + const ScriptConsoleLog({super.key}); + + @override + State createState() => _ScriptConsoleLogState(); +} + +class _ScriptConsoleLogState extends State { + static final List logs = []; + static FloatingWindowManager floatingWindowManager = FloatingWindowManager(); + + final ScrollController _scrollController = ScrollController(); + + AppLocalizations get localizations => AppLocalizations.of(context)!; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((d) { + _scrollController.jumpTo(_scrollController.position.maxScrollExtent); + }); + + if (floatingWindowManager.isShow) { + return; + } + + LogHandler logHandler = LogHandler( + channelId: hashCode, + handle: (log) { + logs.add(log); + + if (!mounted && !floatingWindowManager.isShow) { + logs.clear(); + //关闭日志监听 + ScriptManager.removeLogHandler(hashCode); + return; + } + + if (mounted) { + setState(() {}); + } + }); + + ScriptManager.registerLogHandler(logHandler); + } + + @override + void dispose() { + super.dispose(); + if (!floatingWindowManager.isShow) { + logs.clear(); + ScriptManager.removeLogHandler(hashCode); + } + _scrollController.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(localizations.logger, style: const TextStyle(fontSize: 16)), actions: [ + IconButton( + tooltip: localizations.windowMode, + onPressed: () { + if (floatingWindowManager.isShow) { + floatingWindowManager.hide(); + return; + } + floatingWindowManager.show(context, + widget: ScriptLogSmallWindow(floatingWindowManager: floatingWindowManager)); + }, + icon: const Icon(Icons.picture_in_picture_alt_rounded)), + const SizedBox(width: 5), + IconButton( + tooltip: localizations.clear, + onPressed: () => setState(() { + logs.clear(); + }), + icon: const Icon(Icons.delete)), + const SizedBox(width: 10) + ]), + body: Container( + padding: const EdgeInsets.only(top: 10, bottom: 10, right: 3), + decoration: BoxDecoration(border: Border.all(color: Colors.grey.withOpacity(0.2))), + child: Scrollbar( + controller: _scrollController, + thumbVisibility: true, + thickness: 6, + interactive: true, + child: loggerContent()), + )); + } + + Widget loggerContent() { + return ListView.builder( + controller: _scrollController, + itemCount: logs.length, + itemBuilder: (context, index) { + var log = logs[index]; + Color? color; + if (log.level == 'error') { + color = Colors.red; + } else if (log.level == 'warn') { + color = Colors.orange; + } + + return Padding( + padding: const EdgeInsets.only(bottom: 5, left: 3, right: 3), + child: Row( + children: [ + Text(log.time.timeFormat(), style: const TextStyle(fontSize: 13, color: Colors.grey)), + const SizedBox(width: 8), + Text(log.level, style: TextStyle(fontSize: 13, color: color)), + const SizedBox(width: 8), + Expanded(child: SelectableText(log.output, style: TextStyle(fontSize: 13, color: color))), + ], + )); + }); + } +} + +class ScriptLogSmallWindow extends StatefulWidget { + final FloatingWindowManager floatingWindowManager; + + const ScriptLogSmallWindow({super.key, required this.floatingWindowManager}); + + @override + State createState() => _ScriptLogSmallWindowState(); +} + +class _ScriptLogSmallWindowState extends State { + final List logs = []; + final ScrollController _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + LogHandler logHandler = LogHandler( + channelId: hashCode, + handle: (log) { + logs.add(log); + if (!mounted) { + ScriptManager.removeLogHandler(hashCode); + return; + } + setState(() {}); + _scrollController.jumpTo(_scrollController.position.maxScrollExtent); + }); + ScriptManager.registerLogHandler(logHandler); + } + + @override + void dispose() { + _scrollController.dispose(); + ScriptManager.removeLogHandler(hashCode); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return FloatingWindow( + top: 320, + right: 8, + child: Material( + child: Container( + height: 320, + width: 180, + decoration: BoxDecoration( + color: Colors.teal.withOpacity(0.3), + border: Border.all(color: Colors.grey.withOpacity(0.8)), + borderRadius: const BorderRadius.all(Radius.circular(10))), + child: Stack( + children: [ + Positioned( + top: -12, + left: -5, + child: IconButton( + onPressed: () { + Navigator.of(context) + .push(MaterialPageRoute(builder: (context) => const ScriptConsoleLog())); + }, + icon: const Icon(Icons.picture_in_picture, size: 20))), + Positioned( + top: -12, + right: -8, + child: IconButton( + onPressed: () => widget.floatingWindowManager.hide(), + icon: const Icon(Icons.close, size: 20))), + list() + ], + )))); + } + + Widget list() { + return Padding( + padding: const EdgeInsets.only(bottom: 5, top: 18), + child: Scrollbar( + child: ListView.builder( + controller: _scrollController, + itemCount: logs.length, + itemBuilder: (context, index) { + var log = logs[index]; + return Padding( + padding: const EdgeInsets.only(bottom: 3, left: 3, right: 3), + child: Text(log.output, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 13, color: log.level == 'error' ? Colors.red : null))); + }))); + } +} + /// 编辑脚本 class ScriptEdit extends StatefulWidget { final ScriptItem? scriptItem; @@ -183,10 +406,9 @@ class _ScriptEditState extends State { text: localizations.useGuide, style: const TextStyle(color: Colors.blue, fontSize: 14), recognizer: TapGestureRecognizer() - ..onTap = () => - launchUrl(Uri.parse(isCN - ? 'https://gitee.com/wanghongenpin/network-proxy-flutter/wikis/%E8%84%9A%E6%9C%AC' - : 'https://github.com/wanghongenpin/network_proxy_flutter/wiki/Script')))), + ..onTap = () => launchUrl(Uri.parse(isCN + ? 'https://gitee.com/wanghongenpin/network-proxy-flutter/wikis/%E8%84%9A%E6%9C%AC' + : 'https://github.com/wanghongenpin/network_proxy_flutter/wiki/Script')))), ]), actions: [ TextButton( @@ -240,25 +462,22 @@ class _ScriptEditState extends State { SizedBox(width: 50, child: Text(label)), Expanded( child: TextFormField( - controller: controller, - validator: (val) => val?.isNotEmpty == true ? null : "", - keyboardType: keyboardType, - decoration: InputDecoration( - hintText: hint, - contentPadding: const EdgeInsets.all(10), - errorStyle: const TextStyle(height: 0, fontSize: 0), - focusedBorder: focusedBorder(), - isDense: true, - border: const OutlineInputBorder()), - )) + controller: controller, + validator: (val) => val?.isNotEmpty == true ? null : "", + keyboardType: keyboardType, + decoration: InputDecoration( + hintText: hint, + contentPadding: const EdgeInsets.all(10), + errorStyle: const TextStyle(height: 0, fontSize: 0), + focusedBorder: focusedBorder(), + isDense: true, + border: const OutlineInputBorder()), + )) ]); } InputBorder focusedBorder() { - return OutlineInputBorder(borderSide: BorderSide(color: Theme - .of(context) - .colorScheme - .primary, width: 2)); + return OutlineInputBorder(borderSide: BorderSide(color: Theme.of(context).colorScheme.primary, width: 2)); } } @@ -287,18 +506,18 @@ class _ScriptListState extends State { decoration: BoxDecoration(border: Border.all(color: Colors.grey.withOpacity(0.2))), child: Scrollbar( child: ListView(children: [ - Row( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Container(width: 100, padding: const EdgeInsets.only(left: 10), child: Text(localizations.name)), - SizedBox(width: 50, child: Text(localizations.enable, textAlign: TextAlign.center)), - const VerticalDivider(), - const Expanded(child: Text("URL")), - ], - ), - const Divider(thickness: 0.5), - Column(children: rows(widget.scripts)) - ])))); + Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Container(width: 100, padding: const EdgeInsets.only(left: 10), child: Text(localizations.name)), + SizedBox(width: 50, child: Text(localizations.enable, textAlign: TextAlign.center)), + const VerticalDivider(), + const Expanded(child: Text("URL")), + ], + ), + const Divider(thickness: 0.5), + Column(children: rows(widget.scripts)) + ])))); } globalMenu() { @@ -344,10 +563,7 @@ class _ScriptListState extends State { } List rows(List list) { - var primaryColor = Theme - .of(context) - .colorScheme - .primary; + var primaryColor = Theme.of(context).colorScheme.primary; return List.generate(list.length, (index) { return InkWell( @@ -368,8 +584,8 @@ class _ScriptListState extends State { color: selected.contains(index) ? primaryColor.withOpacity(0.8) : index.isEven - ? Colors.grey.withOpacity(0.1) - : null, + ? Colors.grey.withOpacity(0.1) + : null, height: 45, padding: const EdgeInsets.all(5), child: Row( @@ -433,9 +649,7 @@ class _ScriptListState extends State { _refreshScript(); if (context.mounted) FlutterToastr.show(localizations.importSuccess, context); }), - Container(color: Theme - .of(context) - .hoverColor, height: 8), + Container(color: Theme.of(context).hoverColor, height: 8), TextButton( child: Container( height: 50, @@ -462,7 +676,7 @@ class _ScriptListState extends State { } Navigator.of(context) .push(MaterialPageRoute( - builder: (context) => ScriptEdit(scriptItem: index == null ? null : widget.scripts[index], script: script))) + builder: (context) => ScriptEdit(scriptItem: index == null ? null : widget.scripts[index], script: script))) .then((value) { if (value != null) { setState(() {}); diff --git a/lib/ui/mobile/widgets/floating_window.dart b/lib/ui/mobile/widgets/floating_window.dart new file mode 100644 index 0000000..6066254 --- /dev/null +++ b/lib/ui/mobile/widgets/floating_window.dart @@ -0,0 +1,123 @@ +import 'package:flutter/material.dart'; + +///悬浮小窗口 +class FloatingWindowManager { + static final FloatingWindowManager _instance = FloatingWindowManager._(); + + factory FloatingWindowManager() => _instance; + + FloatingWindowManager._(); + + ///浮窗 + OverlayEntry? overlayEntry; + + bool get isShow => overlayEntry != null; + + void show(BuildContext context, {required Widget widget}) { + if (overlayEntry == null) { + // var floatingWindow = FloatingWindow(top: 160, left: 210, child: Material(child: child)); + overlayEntry = OverlayEntry(builder: (BuildContext context) { + return widget; + }); + Overlay.of(context).insert(overlayEntry!); + } + } + + ///关闭小窗 + void hide() { + overlayEntry?.remove(); + overlayEntry = null; + } +} + +class FloatingWindow extends StatefulWidget { + final Widget child; + final double top; + final double right; + + const FloatingWindow({ + super.key, + required this.child, + required this.top, + required this.right, + }); + + @override + State createState() => _FloatingWindowState(); +} + +class _FloatingWindowState extends State with TickerProviderStateMixin { + double right = 0; + double top = 0; + + double maxX = 0; + double maxY = 0; + + var parentKey = GlobalKey(); + var childKey = GlobalKey(); + + var parentSize = const Size(0, 0); + var childSize = const Size(0, 0); + + void changeState() { + print('"changeState'); + setState(() {}); + } + + @override + void initState() { + right = widget.right; + top = widget.top; + WidgetsBinding.instance.addPostFrameCallback((d) { + parentSize = getWidgetSize(parentKey); + childSize = getWidgetSize(childKey); + maxX = parentSize.width - childSize.width; + maxY = parentSize.height - childSize.height; + }); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Stack( + key: parentKey, + fit: StackFit.expand, + children: [ + Positioned( + key: childKey, + right: right, + top: top, + child: GestureDetector( + onPanUpdate: (d) { + var delta = d.delta; + right -= delta.dx; + top += delta.dy; + setState(() {}); + }, + onPanEnd: (d) { + right = getValue(right, maxX); + top = getValue(top, maxY); + }, + child: widget.child, + ), + ) + ], + ); + } + + ///限制边界 + double getValue(double value, double max) { + if (value < 0) { + return 0; + } else if (value > max) { + return max; + } else { + return value; + } + } + + Size getWidgetSize(GlobalKey key) { + final RenderBox renderBox = key.currentContext?.findRenderObject() as RenderBox; + return renderBox.size; + } +} diff --git a/lib/utils/lang.dart b/lib/utils/lang.dart index b5901a0..4487396 100644 --- a/lib/utils/lang.dart +++ b/lib/utils/lang.dart @@ -17,6 +17,10 @@ extension DateTimeFormat on DateTime { String format() { return formatDate(this, [yyyy, '-', mm, '-', dd, ' ', HH, ':', nn, ':', ss]); } + + String timeFormat() { + return formatDate(this, [HH, ':', nn, ':', ss]); + } } class ValueWrap { @@ -97,3 +101,26 @@ class Maps { return null; } } + +/// 用于存储一些数据,当数据超过指定大小时,删除最早的数据 +class CapacityList { + final int capacity; + final List list = []; + + CapacityList(this.capacity); + + void add(T value) { + if (list.length >= capacity) { + list.removeAt(0); + } + list.add(value); + } + + void remove(T value) { + list.remove(value); + } + + void clear() { + list.clear(); + } +}