diff --git a/lib/network/components/manager/script_manager.dart b/lib/network/components/manager/script_manager.dart index 853df7d..9622ef8 100644 --- a/lib/network/components/manager/script_manager.dart +++ b/lib/network/components/manager/script_manager.dart @@ -97,7 +97,10 @@ async function onResponse(context, request, response) { } static void registerLogHandler(LogHandler logHandler) { - if (!_logHandlers.any((it) => it.channelId == logHandler.channelId)) _logHandlers.add(logHandler); + if (_logHandlers.any((it) => it.channelId == logHandler.channelId)) { + _logHandlers.removeWhere((it) => it.channelId == logHandler.channelId); + } + _logHandlers.add(logHandler); } static void removeLogHandler(int channelId) { @@ -207,13 +210,14 @@ async function onResponse(context, request, response) { } ///添加脚本 - Future addScript(ScriptItem item, String script) async { + Future addScript(ScriptItem item, String? script) async { // Remote script: script is treated as initial cache (optional) if (item.remoteUrl != null && item.remoteUrl!.trim().isNotEmpty) { list.add(item); return; } + script ??= template; final path = await homePath(); String scriptPath = "${separator}scripts$separator${RandomUtil.randomString(16)}.js"; var file = File(path + scriptPath); @@ -416,7 +420,6 @@ class ScriptItem { 'name': name, 'url': urls.length == 1 ? urls[0] : urls, 'scriptPath': scriptPath, - // remote if (remoteUrl != null) 'remoteUrl': remoteUrl, }; } diff --git a/lib/ui/desktop/setting/script.dart b/lib/ui/desktop/setting/script.dart index f8d7a76..b90b098 100644 --- a/lib/ui/desktop/setting/script.dart +++ b/lib/ui/desktop/setting/script.dart @@ -429,7 +429,6 @@ class _ScriptEditState extends State { Widget build(BuildContext context) { GlobalKey formKey = GlobalKey(); bool isCN = Localizations.localeOf(context) == const Locale.fromSubtags(languageCode: 'zh'); - final showRemoteUrl = _useRemote; return AlertDialog( scrollable: true, @@ -605,7 +604,7 @@ class _ScriptEditState extends State { ), // Put Remote URL right after type selector. - if (showRemoteUrl) ...[ + if (_useRemote) ...[ const SizedBox(width: 10), Expanded( flex: 6, @@ -894,7 +893,14 @@ class _ScriptListState extends State { } Future showEdit([int? index]) async { - String? script = index == null ? null : await (await ScriptManager.instance).getScript(widget.scripts[index]); + String? script; + if (index != null) { + var scriptManager = await ScriptManager.instance; + var scriptItem = widget.scripts[index]; + if (scriptItem.remoteUrl == null || scriptItem.remoteUrl?.isEmpty == true) { + script = await scriptManager.getScript(scriptItem); + } + } if (!mounted) { return; } @@ -931,10 +937,11 @@ class _ScriptListState extends State { var item = widget.scripts[idx]; var map = item.toJson(); map.remove("scriptPath"); - // For remote scripts, embed cached content too. - map['script'] = await scriptManager.getScript(item).onError((e, t) { - return ''; - }); + + if (item.remoteUrl != null && item.remoteUrl!.trim().isNotEmpty) { + map['script'] = await scriptManager.getScript(item); + } + json.add(map); } diff --git a/lib/ui/mobile/setting/request_crypto.dart b/lib/ui/mobile/setting/request_crypto.dart index dfa52a5..1833f78 100644 --- a/lib/ui/mobile/setting/request_crypto.dart +++ b/lib/ui/mobile/setting/request_crypto.dart @@ -502,10 +502,11 @@ class _MobileCryptoRuleEditPageState extends State { padding: const EdgeInsets.all(12), children: [ Card( + color: Theme.of(context).colorScheme.surfaceContainerLow.withAlpha((0.5 * 255).round()), elevation: 0, shape: RoundedRectangleBorder( - side: BorderSide(color: Theme.of(context).dividerColor.withValues(alpha: 0.25)), - borderRadius: BorderRadius.circular(10), + side: BorderSide(color: Theme.of(context).dividerColor.withAlpha((0.2 * 255).round())), + borderRadius: BorderRadius.circular(8), ), child: Padding( padding: const EdgeInsets.all(12), @@ -543,10 +544,11 @@ class _MobileCryptoRuleEditPageState extends State { ), const SizedBox(height: 12), Card( + color: Theme.of(context).colorScheme.surfaceContainerLow.withAlpha((0.5 * 255).round()), elevation: 0, shape: RoundedRectangleBorder( - side: BorderSide(color: Theme.of(context).dividerColor.withValues(alpha: 0.25)), - borderRadius: BorderRadius.circular(10), + side: BorderSide(color: Theme.of(context).dividerColor.withAlpha((0.2 * 255).round())), + borderRadius: BorderRadius.circular(8), ), child: Padding( padding: const EdgeInsets.all(12), diff --git a/lib/ui/mobile/setting/script.dart b/lib/ui/mobile/setting/script.dart index 82dc31d..f65fb79 100644 --- a/lib/ui/mobile/setting/script.dart +++ b/lib/ui/mobile/setting/script.dart @@ -20,6 +20,8 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_code_editor/flutter_code_editor.dart'; +import 'package:http/http.dart' as http; +import 'package:get/get.dart'; import 'package:proxypin/l10n/app_localizations.dart'; import 'package:flutter_highlight/themes/monokai-sublime.dart'; import 'package:flutter_toastr/flutter_toastr.dart'; @@ -170,6 +172,8 @@ class ScriptConsoleLog extends StatefulWidget { } class _ScriptConsoleLogState extends State { + int channelId = "ScriptConsoleLog".hashCode; + static final List logs = []; static FloatingWindowManager floatingWindowManager = FloatingWindowManager(); @@ -181,22 +185,19 @@ class _ScriptConsoleLogState extends State { void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((d) { - _scrollController.jumpTo(_scrollController.position.maxScrollExtent); + if (_scrollController.hasClients) { + _scrollController.jumpTo(_scrollController.position.maxScrollExtent); + } }); - if (floatingWindowManager.isShow) { - return; - } - LogHandler logHandler = LogHandler( - channelId: hashCode, + channelId: channelId, handle: (log) { logs.add(log); if (!mounted && !floatingWindowManager.isShow) { logs.clear(); - //关闭日志监听 - ScriptManager.removeLogHandler(hashCode); + ScriptManager.removeLogHandler(channelId); return; } @@ -213,7 +214,7 @@ class _ScriptConsoleLogState extends State { super.dispose(); if (!floatingWindowManager.isShow) { logs.clear(); - ScriptManager.removeLogHandler(hashCode); + ScriptManager.removeLogHandler(channelId); } _scrollController.dispose(); } @@ -315,6 +316,7 @@ class _ScriptLogSmallWindowState extends State { @override void dispose() { _scrollController.dispose(); + logger.d("dispose small window log handler $hashCode"); ScriptManager.removeLogHandler(hashCode); super.dispose(); } @@ -358,6 +360,9 @@ class _ScriptLogSmallWindowState extends State { return Padding( padding: const EdgeInsets.only(bottom: 5, top: 18), child: Scrollbar( + controller: _scrollController, + thumbVisibility: true, + thickness: 2, child: ListView.builder( controller: _scrollController, itemCount: logs.length, @@ -377,10 +382,20 @@ class _ScriptLogSmallWindowState extends State { class ScriptEdit extends StatefulWidget { final ScriptItem? scriptItem; final String? script; + final String? url; final List? urls; final String? title; + final bool fromRemoteUrl; - const ScriptEdit({super.key, this.scriptItem, this.script, this.urls, this.title}); + const ScriptEdit({ + super.key, + this.scriptItem, + this.script, + this.url, + this.urls, + this.title, + this.fromRemoteUrl = false, + }); @override State createState() => _ScriptEditState(); @@ -390,18 +405,25 @@ class _ScriptEditState extends State { late CodeController script; late TextEditingController nameController; late List urlControllers; + late TextEditingController remoteUrlController; + late bool _useRemote; + final RxBool _fetchingRemoteScript = false.obs; AppLocalizations get localizations => AppLocalizations.of(context)!; @override void initState() { super.initState(); - final urls = - widget.scriptItem?.urls ?? (widget.urls != null && widget.urls!.isNotEmpty ? widget.urls! : []); + final urls = widget.scriptItem?.urls ?? + (widget.urls != null && widget.urls!.isNotEmpty + ? widget.urls! + : (widget.url != null && widget.url!.isNotEmpty ? [widget.url!] : [])); 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); + nameController = TextEditingController(text: widget.scriptItem?.name ?? widget.title ?? ''); + remoteUrlController = TextEditingController(text: widget.scriptItem?.remoteUrl ?? ''); + _useRemote = widget.fromRemoteUrl || ((widget.scriptItem?.remoteUrl ?? '').trim().isNotEmpty); } @override @@ -411,9 +433,51 @@ class _ScriptEditState extends State { } script.dispose(); nameController.dispose(); + remoteUrlController.dispose(); + _fetchingRemoteScript.close(); super.dispose(); } + Future _fetchRemoteScript() async { + if (_fetchingRemoteScript.value) return; + final remoteUrl = remoteUrlController.text.trim(); + if (remoteUrl.isEmpty) { + FlutterToastr.show("${localizations.remoteUrl} ${localizations.cannotBeEmpty}", context, + position: FlutterToastr.top); + return; + } + + final uri = Uri.tryParse(remoteUrl); + if (uri == null || !(uri.scheme == 'http' || uri.scheme == 'https')) { + FlutterToastr.show("${localizations.remoteUrl} ${localizations.fail}", context, position: FlutterToastr.top); + return; + } + + try { + _fetchingRemoteScript.value = true; + final resp = await http.get(uri); + if (resp.statusCode < 200 || resp.statusCode >= 300) { + FlutterToastr.show("Fetch failed: HTTP ${resp.statusCode}", context, position: FlutterToastr.top); + return; + } + final content = utf8.decode(resp.bodyBytes); + script.text = content; + if (mounted) { + setState(() {}); + } + } catch (e) { + if (mounted) { + FlutterToastr.show("Fetch failed: $e", context, position: FlutterToastr.top); + } + } finally { + _fetchingRemoteScript.value = false; + } + } + + void _resetScript() { + script.text = ScriptManager.template; + } + @override Widget build(BuildContext context) { GlobalKey formKey = GlobalKey(); @@ -448,14 +512,26 @@ class _ScriptEditState extends State { FlutterToastr.show("URL ${localizations.cannotBeEmpty}", context, position: FlutterToastr.top); return; } + + // Only persist remoteUrl when remote mode is enabled. + final remoteUrl = _useRemote ? remoteUrlController.text.trim() : ''; + final hasRemote = remoteUrl.isNotEmpty; + if (_useRemote && !hasRemote) { + FlutterToastr.show("Remote URL ${localizations.cannotBeEmpty}", context, + position: FlutterToastr.top); + return; + } + var scriptManager = await ScriptManager.instance; if (widget.scriptItem == null) { var scriptItem = ScriptItem(true, nameController.text, urls); + scriptItem.remoteUrl = _useRemote ? remoteUrl : null; await scriptManager.addScript(scriptItem, script.text); } else { widget.scriptItem?.name = nameController.text; widget.scriptItem?.urls = urls; widget.scriptItem?.urlRegs = null; + widget.scriptItem?.remoteUrl = _useRemote ? remoteUrl : null; await scriptManager.updateScript(widget.scriptItem!, script.text); } @@ -474,23 +550,24 @@ class _ScriptEditState extends State { children: [ // Name section Card( + color: Theme.of(context).colorScheme.surfaceContainerLow.withOpacity(0.5), elevation: 0, shape: RoundedRectangleBorder( side: BorderSide(color: Theme.of(context).dividerColor.withOpacity(0.4)), borderRadius: BorderRadius.circular(8)), child: Padding( - padding: const EdgeInsets.all(10), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), child: textField("${localizations.name}:", nameController, localizations.pleaseEnter))), - const SizedBox(height: 10), // URLs section Card( + color: Theme.of(context).colorScheme.surfaceContainerLow.withOpacity(0.5), elevation: 0, shape: RoundedRectangleBorder( side: BorderSide(color: Theme.of(context).dividerColor.withOpacity(0.4)), borderRadius: BorderRadius.circular(8)), child: Padding( - padding: const EdgeInsets.all(10), + padding: const EdgeInsets.symmetric(horizontal: 10), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ const Text("URL(s):"), @@ -535,16 +612,103 @@ class _ScriptEditState extends State { }), ]))) ]))), - const SizedBox(height: 10), - // Script section + // Source section Card( + color: Theme.of(context).colorScheme.surfaceContainerLow.withOpacity(0.5), elevation: 0, shape: RoundedRectangleBorder( side: BorderSide(color: Theme.of(context).dividerColor.withOpacity(0.4)), borderRadius: BorderRadius.circular(8)), child: Padding( - padding: const EdgeInsets.all(10), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + child: Row(children: [ + SizedBox(width: 55, child: Text('${localizations.type}:')), + Expanded( + child: DropdownButtonFormField( + initialValue: _useRemote, + items: [ + DropdownMenuItem(value: false, child: Text(localizations.local)), + DropdownMenuItem(value: true, child: Text(localizations.remoteUrl)), + ], + onChanged: (val) { + if (val == null) return; + setState(() { + _useRemote = val; + }); + }, + decoration: InputDecoration( + contentPadding: const EdgeInsets.all(10), + focusedBorder: focusedBorder(), + isDense: true, + border: const OutlineInputBorder(), + ), + )) + ]))), + + // Remote URL section + if (_useRemote) + Card( + color: Theme.of(context).colorScheme.surfaceContainerLow.withOpacity(0.5), + elevation: 0, + shape: RoundedRectangleBorder( + side: BorderSide(color: Theme.of(context).dividerColor.withOpacity(0.4)), + borderRadius: BorderRadius.circular(8)), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + child: Row(children: [ + SizedBox(width: 65, child: Text('${localizations.remoteUrl}:')), + Expanded( + child: SizedBox( + height: 34, + child: TextFormField( + controller: remoteUrlController, + keyboardType: TextInputType.url, + decoration: InputDecoration( + hintText: 'https://example.com/script.js', + hintStyle: const TextStyle(fontSize: 14, color: Colors.grey), + contentPadding: const EdgeInsets.all(10), + focusedBorder: focusedBorder(), + isDense: true, + border: const OutlineInputBorder(), + ), + onFieldSubmitted: (_) => _fetchRemoteScript(), + ), + ), + ), + const SizedBox(width: 3), + Obx(() { + // Keep the button visually aligned with the text field by fixing the height + // and using a compact FilledButton (with icon when idle and spinner when fetching). + return SizedBox( + height: 34, + child: Tooltip( + message: localizations.view, + child: FilledButton.tonal( + onPressed: _fetchRemoteScript, + style: FilledButton.styleFrom( + minimumSize: const Size(44, 34), + padding: const EdgeInsets.symmetric(horizontal: 8), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6))), + child: _fetchingRemoteScript.value + ? const SizedBox( + width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.cloud_download, size: 18), + ), + ), + ); + }), + ]))), + + // Script section + Card( + color: Theme.of(context).colorScheme.surfaceContainerLow.withOpacity(0.5), + elevation: 0, + shape: RoundedRectangleBorder( + side: BorderSide(color: Theme.of(context).dividerColor.withOpacity(0.4)), + borderRadius: BorderRadius.circular(8)), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ Text("${localizations.script}:", style: const TextStyle(fontWeight: FontWeight.w500)), @@ -558,27 +722,10 @@ class _ScriptEditState extends State { FlutterToastr.show(localizations.copied, context, position: FlutterToastr.top); })), Tooltip( - message: 'Paste', + message: 'Reset', child: IconButton( - icon: const Icon(Icons.content_paste_go_outlined, size: 20), - onPressed: () async { - final data = await Clipboard.getData('text/plain'); - final paste = data?.text; - if (paste == null || paste.isEmpty) return; - final sel = script.selection; - if (sel.isValid) { - final text = script.text; - final start = sel.start; - final end = sel.end; - final newText = text.replaceRange(start, end, paste); - script.value = script.value.copyWith( - text: newText, - selection: TextSelection.collapsed(offset: start + paste.length)); - } else { - script.text += paste; - } - setState(() {}); - })), + icon: const Icon(Icons.settings_backup_restore, size: 22), + onPressed: _resetScript)), Tooltip( message: localizations.clear, child: IconButton( @@ -588,7 +735,6 @@ class _ScriptEditState extends State { setState(() {}); })) ]), - const SizedBox(height: 6), CodeTheme( data: CodeThemeData(styles: monokaiSublimeTheme), child: ClipRRect( @@ -599,19 +745,20 @@ class _ScriptEditState extends State { border: Border.all(color: Colors.grey.withOpacity(0.2))), child: SingleChildScrollView( child: CodeField( - textStyle: const TextStyle(fontSize: 13, color: Colors.white), - enableSuggestions: true, - gutterStyle: const GutterStyle(width: 50, margin: 0), - onTapOutside: (event) => FocusScope.of(context).unfocus(), - controller: script))))), - ]))) + readOnly: _useRemote, + enableSuggestions: true, + textStyle: const TextStyle(fontSize: 13, color: Colors.white), + controller: script, + gutterStyle: const GutterStyle(width: 50, margin: 0), + ))))), + ]))), ], ))); } Widget textField(String label, TextEditingController controller, String hint, {TextInputType? keyboardType}) { return Row(children: [ - SizedBox(width: 50, child: Text(label)), + SizedBox(width: 65, child: Text(label)), Expanded( child: TextFormField( controller: controller, @@ -620,6 +767,7 @@ class _ScriptEditState extends State { decoration: InputDecoration( hintText: hint, contentPadding: const EdgeInsets.all(10), + hintStyle: const TextStyle(fontSize: 14, color: Colors.grey), errorStyle: const TextStyle(height: 0, fontSize: 0), focusedBorder: focusedBorder(), isDense: true, @@ -718,6 +866,9 @@ class _ScriptListState extends State { var primaryColor = Theme.of(context).colorScheme.primary; return List.generate(list.length, (index) { + final item = list[index]; + final isRemote = item.remoteUrl != null && item.remoteUrl!.trim().isNotEmpty; + return InkWell( splashColor: primaryColor.withOpacity(0.3), onTap: () async { @@ -744,8 +895,13 @@ class _ScriptListState extends State { children: [ SizedBox( width: 100, - child: Text(list[index].name!, - style: const TextStyle(fontSize: 13), overflow: TextOverflow.ellipsis)), + child: Row(children: [ + Expanded(child: Text(list[index].name ?? '', style: const TextStyle(fontSize: 13))), + if (isRemote) + const Padding( + padding: EdgeInsets.only(left: 6), + child: Text('R', style: TextStyle(fontSize: 11, color: Colors.blue))), + ])), SizedBox( width: 50, child: Transform.scale( @@ -825,11 +981,19 @@ class _ScriptListState extends State { }); } - showEdit([int? index]) async { - String? script = index == null ? null : await (await ScriptManager.instance).getScript(widget.scripts[index]); + Future showEdit([int? index]) async { + String? script; + if (index != null) { + var scriptManager = await ScriptManager.instance; + var scriptItem = widget.scripts[index]; + if (scriptItem.remoteUrl == null || scriptItem.remoteUrl?.isEmpty == true) { + script = await scriptManager.getScript(scriptItem); + } + } if (!mounted) { return; } + Navigator.of(context) .push(MaterialPageRoute( builder: (context) => ScriptEdit(scriptItem: index == null ? null : widget.scripts[index], script: script))) @@ -841,7 +1005,7 @@ class _ScriptListState extends State { } //导出js - export(BuildContext context, List indexes) async { + Future export(BuildContext context, List indexes) async { if (indexes.isEmpty) return; //文件名称 String fileName = 'proxypin-scripts.json'; @@ -851,7 +1015,9 @@ class _ScriptListState extends State { var item = widget.scripts[idx]; var map = item.toJson(); map.remove("scriptPath"); - map['script'] = await scriptManager.getScript(item); + if (item.remoteUrl != null && item.remoteUrl!.trim().isNotEmpty) { + map['script'] = await scriptManager.getScript(item); + } json.add(map); } diff --git a/pubspec.yaml b/pubspec.yaml index d431299..3f7feb9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -35,7 +35,7 @@ dependencies: ref: secure-keyboard flutter_highlight: ^0.7.0 flutter_desktop_context_menu: ^0.2.0 - device_info_plus: ^11.5.0 + device_info_plus: ^12.3.0 shared_preferences: ^2.5.4 url_launcher: ^6.3.2 toastification: ^3.0.2