8 Commits

Author SHA1 Message Date
gongyun c143ae4c1c Merge branch 'main' into dev 2026-06-04 21:40:25 +08:00
gongyun 45e487e7a0 Merge branch 'dev' of ssh://git.saont.net:222/gongyun/app into dev 2026-06-04 21:38:09 +08:00
gongyun 7afa4bf156 构建修复 2026-06-04 21:33:07 +08:00
gongyun 18def0753f Merge pull request '上游源码合并与功能更新' (#9) from dev into main
Android APK Release / Build Android APK (push) Failing after 25m9s
Reviewed-on: #9
  - 上游源码合并增补相关功能与补丁
  - v1.3.6版本开始付费用户可以自行选择中国大陆地区优化专线节点以及手动申请独享专线节点
    - 进入优化专线节点方式为各客户端中设置页面下"网络测试"选项进入即可看到“自定义线路”选项
2026-06-04 18:55:25 +08:00
gongyun 526990a01a Merge branch 'main' into dev 2026-06-04 18:52:43 +08:00
gongyun db10873aaf 构建环境镜像添加 2026-06-04 18:17:10 +08:00
gongyun e6c60a9d6d 自定义线路与线路优化 2026-06-04 17:52:16 +08:00
gongyun 3b94f40def Merge pull request '新增网络测试功能,预留付费用户线路自定义与线路自选入口' (#8) from dev into main
Android APK Release / Build Android APK (push) Successful in 1h6m5s
Reviewed-on: #8
2026-05-31 10:23:10 +08:00
18 changed files with 1201 additions and 209 deletions
+6 -3
View File
@@ -12,6 +12,9 @@ permissions:
contents: write contents: write
releases: write releases: write
env:
FLUTTER_STORAGE_BASE_URL: https://storage.flutter-io.cn
jobs: jobs:
android: android:
name: Build Android APK name: Build Android APK
@@ -113,7 +116,7 @@ jobs:
flutter doctor -v flutter doctor -v
- name: Install dependencies - name: Install dependencies
run: flutter pub get run: flutter pub get --enforce-lockfile
- name: Restore Android signing files - name: Restore Android signing files
shell: bash shell: bash
@@ -145,7 +148,7 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
rm -rf build dist .dart_tool android/.gradle rm -rf build dist android/.gradle
pkg_dir="$PWD/dist/android" pkg_dir="$PWD/dist/android"
if [ -d "$pkg_dir" ]; then if [ -d "$pkg_dir" ]; then
@@ -153,7 +156,7 @@ jobs:
rm -rf "$pkg_dir" rm -rf "$pkg_dir"
fi fi
build_args=(--release) build_args=(--release --no-pub)
if [ -n "${IP_NODE_API_BASE_URL:-}" ]; then if [ -n "${IP_NODE_API_BASE_URL:-}" ]; then
build_args+=("--dart-define=IP_NODE_API_BASE_URL=${IP_NODE_API_BASE_URL}") build_args+=("--dart-define=IP_NODE_API_BASE_URL=${IP_NODE_API_BASE_URL}")
fi fi
+11 -3
View File
@@ -160,6 +160,14 @@ Linux: Debian 12
flutter pub get flutter pub get
``` ```
如果当前网络无法解析 `pub.dev`,先设置 Pub/Flutter 镜像:
```powershell
$env:PUB_HOSTED_URL="https://pub.flutter-io.cn"
$env:FLUTTER_STORAGE_BASE_URL="https://storage.flutter-io.cn"
flutter pub get
```
### 运行项目 ### 运行项目
```bash ```bash
@@ -170,11 +178,11 @@ flutter run # pdf 和 音视频会再构建过程中下载github上的依赖,
```bash ```bash
# Android # Android
flutter build apk --release flutter build apk --release --no-pub
# Linux # Linux
flutter build -d linux --release flutter build -d linux --release --no-pub
# windows # windows
flutter build -d windows --release flutter build -d windows --release --no-pub
``` ```
--- ---
+1
View File
@@ -14,6 +14,7 @@ class StorageKeys {
static const String downloadTasks = 'download_tasks'; static const String downloadTasks = 'download_tasks';
static const String downloadWifiOnly = 'download_wifi_only'; static const String downloadWifiOnly = 'download_wifi_only';
static const String downloadRetries = 'download_retries'; static const String downloadRetries = 'download_retries';
static const String selectedCustomLineNode = 'selected_custom_line_node';
// 任务记录 // 任务记录
static const String taskRetentionDays = 'task_retention_days'; static const String taskRetentionDays = 'task_retention_days';
+2
View File
@@ -29,6 +29,7 @@ import 'services/storage_service.dart';
import 'services/server_service.dart'; import 'services/server_service.dart';
import 'services/cache_manager_service.dart'; import 'services/cache_manager_service.dart';
import 'services/avatar_cache_service.dart'; import 'services/avatar_cache_service.dart';
import 'services/host_mapping_proxy_service.dart';
import 'core/utils/video_fullscreen.dart'; import 'core/utils/video_fullscreen.dart';
import 'services/desktop_service.dart'; import 'services/desktop_service.dart';
import 'router/app_router.dart'; import 'router/app_router.dart';
@@ -51,6 +52,7 @@ Level _parseLogLevel(String level) {
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
HttpOverrides.global = HostMappingHttpOverrides();
await AppLogger.init(); await AppLogger.init();
final savedLevel = await StorageService.instance.getString( final savedLevel = await StorageService.instance.getString(
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart'; import 'package:media_kit/media_kit.dart';
import '../../../data/models/file_model.dart'; import '../../../data/models/file_model.dart';
import '../../../services/custom_line_service.dart';
import '../../../services/file_service.dart'; import '../../../services/file_service.dart';
/// 音频预览页面 /// 音频预览页面
@@ -43,7 +44,11 @@ class _AudioPreviewPageState extends State<AudioPreviewPage> {
setState(() { setState(() {
_isLoading = false; _isLoading = false;
}); });
player.open(Media(url), play: true); await CustomLineService.instance.configureMediaPlayerProxy(
player,
url,
);
await player.open(Media(url), play: true);
} }
} else { } else {
if (mounted) { if (mounted) {
@@ -104,11 +109,7 @@ class _AudioPreviewPageState extends State<AudioPreviewPage> {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const Icon( const Icon(Icons.error_outline, size: 64, color: Colors.white54),
Icons.error_outline,
size: 64,
color: Colors.white54,
),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
_errorMessage!, _errorMessage!,
@@ -140,14 +141,17 @@ class AudioPlayerWidget extends StatefulWidget {
final Player player; final Player player;
final String fileName; final String fileName;
const AudioPlayerWidget({super.key, required this.player, required this.fileName}); const AudioPlayerWidget({
super.key,
required this.player,
required this.fileName,
});
@override @override
State<AudioPlayerWidget> createState() => _AudioPlayerWidgetState(); State<AudioPlayerWidget> createState() => _AudioPlayerWidgetState();
} }
class _AudioPlayerWidgetState extends State<AudioPlayerWidget> { class _AudioPlayerWidgetState extends State<AudioPlayerWidget> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
@@ -155,10 +159,7 @@ class _AudioPlayerWidgetState extends State<AudioPlayerWidget> {
gradient: LinearGradient( gradient: LinearGradient(
begin: Alignment.topCenter, begin: Alignment.topCenter,
end: Alignment.bottomCenter, end: Alignment.bottomCenter,
colors: [ colors: [const Color(0xFF1A1A2E), const Color(0xFF16213E)],
const Color(0xFF1A1A2E),
const Color(0xFF16213E),
],
), ),
), ),
child: Padding( child: Padding(
@@ -246,12 +247,18 @@ class _AudioPlayerWidgetState extends State<AudioPlayerWidget> {
return SliderTheme( return SliderTheme(
data: SliderThemeData( data: SliderThemeData(
trackHeight: 4, trackHeight: 4,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 8), thumbShape: const RoundSliderThumbShape(
overlayShape: const RoundSliderOverlayShape(overlayRadius: 16), enabledThumbRadius: 8,
),
overlayShape: const RoundSliderOverlayShape(
overlayRadius: 16,
),
activeTrackColor: const Color(0xFFE94560), activeTrackColor: const Color(0xFFE94560),
inactiveTrackColor: Colors.white.withValues(alpha: 0.1), inactiveTrackColor: Colors.white.withValues(alpha: 0.1),
thumbColor: const Color(0xFFE94560), thumbColor: const Color(0xFFE94560),
overlayColor: const Color(0xFFE94560).withValues(alpha: 0.3), overlayColor: const Color(
0xFFE94560,
).withValues(alpha: 0.3),
), ),
child: Slider( child: Slider(
value: position.inMilliseconds.toDouble(), value: position.inMilliseconds.toDouble(),
@@ -259,9 +266,7 @@ class _AudioPlayerWidgetState extends State<AudioPlayerWidget> {
? duration.inMilliseconds.toDouble() ? duration.inMilliseconds.toDouble()
: 1.0, : 1.0,
onChanged: (value) { onChanged: (value) {
widget.player.seek( widget.player.seek(Duration(milliseconds: value.toInt()));
Duration(milliseconds: value.toInt()),
);
}, },
), ),
); );
@@ -8,6 +8,7 @@ import 'package:highlight/highlight.dart';
import 'package:highlight/languages/all.dart'; import 'package:highlight/languages/all.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '../../../data/models/file_model.dart'; import '../../../data/models/file_model.dart';
import '../../../services/custom_line_service.dart';
import '../../../services/file_service.dart'; import '../../../services/file_service.dart';
import '../../../core/utils/file_type_utils.dart'; import '../../../core/utils/file_type_utils.dart';
@@ -62,6 +63,7 @@ class _DocumentPreviewPageState extends State<DocumentPreviewPage> {
if (urls.isEmpty) throw Exception('获取URL为空'); if (urls.isEmpty) throw Exception('获取URL为空');
final urlData = urls[0] as Map<String, dynamic>; final urlData = urls[0] as Map<String, dynamic>;
final url = urlData['url'] as String; final url = urlData['url'] as String;
await CustomLineService.instance.activateSelectedNodeForUrl(url);
final responseContent = await http.get(Uri.parse(url)); final responseContent = await http.get(Uri.parse(url));
if (responseContent.statusCode != 200) { if (responseContent.statusCode != 200) {
@@ -93,7 +95,8 @@ class _DocumentPreviewPageState extends State<DocumentPreviewPage> {
_codeController = CodeController(text: _content, language: _languageMode); _codeController = CodeController(text: _content, language: _languageMode);
} }
int _countLines(String text) => text.isEmpty ? 0 : '\n'.allMatches(text).length + 1; int _countLines(String text) =>
text.isEmpty ? 0 : '\n'.allMatches(text).length + 1;
Mode _detectLanguageMode(String fileName) { Mode _detectLanguageMode(String fileName) {
final ext = FileTypeUtils.getExtension(fileName); final ext = FileTypeUtils.getExtension(fileName);
@@ -131,16 +134,21 @@ class _DocumentPreviewPageState extends State<DocumentPreviewPage> {
appBar: AppBar( appBar: AppBar(
backgroundColor: _backgroundColor, backgroundColor: _backgroundColor,
elevation: 0, elevation: 0,
iconTheme: const IconThemeData( iconTheme: const IconThemeData(color: Colors.white),
color: Colors.white,
),
title: Column( title: Column(
// crossAxisAlignment: CrossAxisAlignment.start, // crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(widget.file.name, style: const TextStyle(color: Colors.white, fontSize: 15), textAlign: TextAlign.center,), Text(
widget.file.name,
style: const TextStyle(color: Colors.white, fontSize: 15),
textAlign: TextAlign.center,
),
if (!_isLoading) if (!_isLoading)
Text('$_languageName · $_lineCount 行 · 字号: ${_fontSize.toInt()}', Text(
style: TextStyle(color: Colors.grey.shade400, fontSize: 12), textAlign: TextAlign.center,), '$_languageName · $_lineCount 行 · 字号: ${_fontSize.toInt()}',
style: TextStyle(color: Colors.grey.shade400, fontSize: 12),
textAlign: TextAlign.center,
),
], ],
), ),
actions: [ actions: [
@@ -159,60 +167,64 @@ class _DocumentPreviewPageState extends State<DocumentPreviewPage> {
/// 现在是一次性渲染的, 所以代码行数太多会有性能问题, 渲染会耗时较长, 而且会卡顿 /// 现在是一次性渲染的, 所以代码行数太多会有性能问题, 渲染会耗时较长, 而且会卡顿
Widget _buildCodeEditor(double lineNumberWidth) { Widget _buildCodeEditor(double lineNumberWidth) {
// 核心改进:根据当前字号,动态计算一个更宽松的行号宽度 // 核心改进:根据当前字号,动态计算一个更宽松的行号宽度
// 13号字大概每个数字占 8-9 像素,我们按 10 像素算并加上边距 // 13号字大概每个数字占 8-9 像素,我们按 10 像素算并加上边距
int digits = _lineCount.toString().length; int digits = _lineCount.toString().length;
// 这里的 12.0 是根据 13-15号字体的平均宽度预留,46 是基础间距 (调试火葬场) // 这里的 12.0 是根据 13-15号字体的平均宽度预留,46 是基础间距 (调试火葬场)
double stableWidth = _showLineNumbers double stableWidth = _showLineNumbers
? (digits * (_fontSize * 0.75)) + 46 ? (digits * (_fontSize * 0.75)) + 46
: 0; : 0;
return Container( return Container(
margin: const EdgeInsets.symmetric(horizontal: 2, vertical: 2), margin: const EdgeInsets.symmetric(horizontal: 2, vertical: 2),
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
decoration: BoxDecoration( decoration: BoxDecoration(
color: _backgroundColor, color: _backgroundColor,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
),
child: ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(
dragDevices: {PointerDeviceKind.touch, PointerDeviceKind.mouse},
), ),
child: ScrollConfiguration( child: CodeTheme(
behavior: ScrollConfiguration.of(context).copyWith( data: CodeThemeData(styles: {...atomOneDarkTheme}),
dragDevices: {PointerDeviceKind.touch, PointerDeviceKind.mouse}, child: Scrollbar(
), controller: _customCodeScrollController,
child: CodeTheme( child: SingleChildScrollView(
data: CodeThemeData(styles: {...atomOneDarkTheme}),
child: Scrollbar(
controller: _customCodeScrollController, controller: _customCodeScrollController,
child: SingleChildScrollView( child: CodeField(
controller: _customCodeScrollController, controller: _codeController!,
child: CodeField( textStyle: TextStyle(
controller: _codeController!, fontFamily: 'SourceCodePro',
fontSize: _fontSize,
height: 1.5,
),
enabled: false,
readOnly: true,
background: Colors.transparent,
// 关键点 1:调整行号样式
lineNumberStyle: LineNumberStyle(
width: stableWidth,
textAlign: TextAlign.right, // 回归右对齐,更符合代码习惯
margin: _showLineNumbers ? 12 : 0, // 增加间距防止数字贴边触发换行
textStyle: TextStyle( textStyle: TextStyle(
fontFamily: 'SourceCodePro', color: _showLineNumbers
fontSize: _fontSize, ? Colors.grey.shade600
height: 1.5, : Colors.transparent,
), fontSize: _fontSize * 0.8,
enabled: false, height: 1.5, // 必须和正文高度完全一致
readOnly: true, // 核心修复:通过强制单词不换行来防止数字断裂
background: Colors.transparent, fontFeatures: const [
// 关键点 1:调整行号样式 FontFeature.tabularFigures(),
lineNumberStyle: LineNumberStyle( ], // 使用等宽数字
width: stableWidth,
textAlign: TextAlign.right, // 回归右对齐,更符合代码习惯
margin: _showLineNumbers ? 12 : 0, // 增加间距防止数字贴边触发换行
textStyle: TextStyle(
color: _showLineNumbers ? Colors.grey.shade600 : Colors.transparent,
fontSize: _fontSize * 0.8,
height: 1.5, // 必须和正文高度完全一致
// 核心修复:通过强制单词不换行来防止数字断裂
fontFeatures: const [FontFeature.tabularFigures()], // 使用等宽数字
),
), ),
), ),
), ),
), ),
), ),
), ),
); ),
} );
}
// 构建功能组合按钮 // 构建功能组合按钮
Widget _buildExpandableFab() { Widget _buildExpandableFab() {
@@ -225,7 +237,9 @@ class _DocumentPreviewPageState extends State<DocumentPreviewPage> {
heroTag: 'font_up', heroTag: 'font_up',
mini: true, mini: true,
backgroundColor: Colors.grey.shade800, backgroundColor: Colors.grey.shade800,
onPressed: () => setState(() { if(_fontSize < 30) _fontSize++; }), onPressed: () => setState(() {
if (_fontSize < 30) _fontSize++;
}),
child: const Icon(Icons.add, color: Colors.white, size: 20), child: const Icon(Icons.add, color: Colors.white, size: 20),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -234,7 +248,9 @@ class _DocumentPreviewPageState extends State<DocumentPreviewPage> {
heroTag: 'font_down', heroTag: 'font_down',
mini: true, mini: true,
backgroundColor: Colors.grey.shade800, backgroundColor: Colors.grey.shade800,
onPressed: () => setState(() { if(_fontSize > 8) _fontSize--; }), onPressed: () => setState(() {
if (_fontSize > 8) _fontSize--;
}),
child: const Icon(Icons.remove, color: Colors.white, size: 20), child: const Icon(Icons.remove, color: Colors.white, size: 20),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -242,7 +258,9 @@ class _DocumentPreviewPageState extends State<DocumentPreviewPage> {
FloatingActionButton( FloatingActionButton(
heroTag: 'line_toggle', heroTag: 'line_toggle',
mini: true, mini: true,
backgroundColor: _showLineNumbers ? Colors.blue : Colors.grey.shade800, backgroundColor: _showLineNumbers
? Colors.blue
: Colors.grey.shade800,
onPressed: () => setState(() => _showLineNumbers = !_showLineNumbers), onPressed: () => setState(() => _showLineNumbers = !_showLineNumbers),
child: Icon( child: Icon(
_showLineNumbers ? Icons.format_list_numbered : Icons.short_text, _showLineNumbers ? Icons.format_list_numbered : Icons.short_text,
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:photo_view/photo_view.dart'; import 'package:photo_view/photo_view.dart';
import '../../../data/models/file_model.dart'; import '../../../data/models/file_model.dart';
import '../../../services/custom_line_service.dart';
import '../../../services/file_service.dart'; import '../../../services/file_service.dart';
import '../../../services/cache_manager_service.dart'; import '../../../services/cache_manager_service.dart';
import '../../widgets/toast_helper.dart'; import '../../widgets/toast_helper.dart';
@@ -44,6 +45,7 @@ class _ImagePreviewPageState extends State<ImagePreviewPage> {
if (urls.isNotEmpty) { if (urls.isNotEmpty) {
final urlData = urls[0] as Map<String, dynamic>; final urlData = urls[0] as Map<String, dynamic>;
final url = urlData['url'] as String; final url = urlData['url'] as String;
await CustomLineService.instance.activateSelectedNodeForUrl(url);
if (mounted) { if (mounted) {
setState(() { setState(() {
@@ -11,6 +11,7 @@ import 'package:markdown_widget/widget/blocks/leaf/link.dart';
import 'package:markdown_widget/widget/markdown.dart'; import 'package:markdown_widget/widget/markdown.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import '../../../data/models/file_model.dart'; import '../../../data/models/file_model.dart';
import '../../../services/custom_line_service.dart';
import '../../../services/file_service.dart'; import '../../../services/file_service.dart';
class MarkdownPreviewPage extends StatefulWidget { class MarkdownPreviewPage extends StatefulWidget {
@@ -62,6 +63,7 @@ class _MarkdownPreviewPageState extends State<MarkdownPreviewPage> {
final urlData = urls[0] as Map<String, dynamic>; final urlData = urls[0] as Map<String, dynamic>;
final url = urlData['url'] as String; final url = urlData['url'] as String;
await CustomLineService.instance.activateSelectedNodeForUrl(url);
final responseContent = await http.get(Uri.parse(url)); final responseContent = await http.get(Uri.parse(url));
if (responseContent.statusCode != 200) { if (responseContent.statusCode != 200) {
@@ -104,7 +106,10 @@ class _MarkdownPreviewPageState extends State<MarkdownPreviewPage> {
backgroundColor: bgColor, backgroundColor: bgColor,
elevation: 0, elevation: 0,
leading: IconButton( leading: IconButton(
icon: Icon(Icons.arrow_back, color: isDark ? Colors.white : Colors.black87), icon: Icon(
Icons.arrow_back,
color: isDark ? Colors.white : Colors.black87,
),
onPressed: () => Navigator.of(context).pop(), onPressed: () => Navigator.of(context).pop(),
), ),
title: Column( title: Column(
@@ -120,13 +125,19 @@ class _MarkdownPreviewPageState extends State<MarkdownPreviewPage> {
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
if (!_isLoading && _error == null) if (!_isLoading && _error == null)
Text('Markdown 预览', style: TextStyle(color: Colors.grey, fontSize: 12)), Text(
'Markdown 预览',
style: TextStyle(color: Colors.grey, fontSize: 12),
),
], ],
), ),
actions: [ actions: [
if (!_isLoading && _error == null) if (!_isLoading && _error == null)
IconButton( IconButton(
icon: Icon(Icons.copy, color: isDark ? Colors.white : Colors.black87), icon: Icon(
Icons.copy,
color: isDark ? Colors.white : Colors.black87,
),
onPressed: () => Clipboard.setData(ClipboardData(text: _content)), onPressed: () => Clipboard.setData(ClipboardData(text: _content)),
), ),
], ],
@@ -134,8 +145,8 @@ class _MarkdownPreviewPageState extends State<MarkdownPreviewPage> {
body: _isLoading body: _isLoading
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: _error != null : _error != null
? _buildErrorWidget() ? _buildErrorWidget()
: _buildResponsiveBody(isWideScreen, screenWidth), : _buildResponsiveBody(isWideScreen, screenWidth),
floatingActionButton: _buildFAB(isDark), floatingActionButton: _buildFAB(isDark),
); );
} }
@@ -159,10 +170,14 @@ class _MarkdownPreviewPageState extends State<MarkdownPreviewPage> {
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border( border: Border(
right: BorderSide( right: BorderSide(
color: _isDarkMode ? Colors.white10 : Colors.grey.withValues(alpha: 0.2) color: _isDarkMode
? Colors.white10
: Colors.grey.withValues(alpha: 0.2),
), ),
), ),
color: _isDarkMode ? const Color(0xFF252525) : Colors.grey.shade50, color: _isDarkMode
? const Color(0xFF252525)
: Colors.grey.shade50,
), ),
child: ClipRect( child: ClipRect(
child: Column( child: Column(
@@ -178,7 +193,10 @@ class _MarkdownPreviewPageState extends State<MarkdownPreviewPage> {
), ),
), ),
), ),
Divider(height: 1, color: _isDarkMode ? Colors.white10 : Colors.grey.shade300), Divider(
height: 1,
color: _isDarkMode ? Colors.white10 : Colors.grey.shade300,
),
Expanded( Expanded(
child: GestureDetector( child: GestureDetector(
onTap: () { onTap: () {
@@ -198,9 +216,7 @@ class _MarkdownPreviewPageState extends State<MarkdownPreviewPage> {
bodySmall: TextStyle(color: subTextColor), bodySmall: TextStyle(color: subTextColor),
), ),
), ),
child: TocWidget( child: TocWidget(controller: _tocController),
controller: _tocController,
),
), ),
), ),
), ),
@@ -228,8 +244,11 @@ class _MarkdownPreviewPageState extends State<MarkdownPreviewPage> {
} }
Widget _buildMarkdownWidget(String content) { Widget _buildMarkdownWidget(String content) {
final config = _isDarkMode ? MarkdownConfig.darkConfig : MarkdownConfig.defaultConfig; final config = _isDarkMode
CodeWrapperWidget codeWrapper(child, text, language) => CodeWrapperWidget(child, text, language); ? MarkdownConfig.darkConfig
: MarkdownConfig.defaultConfig;
CodeWrapperWidget codeWrapper(child, text, language) =>
CodeWrapperWidget(child, text, language);
return MarkdownWidget( return MarkdownWidget(
data: content, data: content,
@@ -237,7 +256,10 @@ class _MarkdownPreviewPageState extends State<MarkdownPreviewPage> {
config: config.copy( config: config.copy(
configs: [ configs: [
_isDarkMode _isDarkMode
? PreConfig.darkConfig.copy(theme: a11yLightTheme, wrapper: codeWrapper) ? PreConfig.darkConfig.copy(
theme: a11yLightTheme,
wrapper: codeWrapper,
)
: PreConfig(theme: atomOneDarkTheme).copy(wrapper: codeWrapper), : PreConfig(theme: atomOneDarkTheme).copy(wrapper: codeWrapper),
LinkConfig( LinkConfig(
style: TextStyle( style: TextStyle(
@@ -266,12 +288,14 @@ class _MarkdownPreviewPageState extends State<MarkdownPreviewPage> {
); );
} }
Widget _buildFAB(bool isDark) { Widget _buildFAB(bool isDark) {
if (_isLoading || _error != null) return const SizedBox.shrink(); if (_isLoading || _error != null) return const SizedBox.shrink();
// 定义统一的按钮背景颜色,增加视觉一致性 // 定义统一的按钮背景颜色,增加视觉一致性
final Color activeColor = isDark ? Colors.blue.shade700 : Colors.blue; final Color activeColor = isDark ? Colors.blue.shade700 : Colors.blue;
final Color inactiveColor = isDark ? Colors.grey.shade800 : Colors.grey.shade200; final Color inactiveColor = isDark
? Colors.grey.shade800
: Colors.grey.shade200;
final Color iconColor = isDark ? Colors.white : Colors.black87; final Color iconColor = isDark ? Colors.white : Colors.black87;
return Column( return Column(
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:pdfrx/pdfrx.dart'; import 'package:pdfrx/pdfrx.dart';
import '../../../data/models/file_model.dart'; import '../../../data/models/file_model.dart';
import '../../../services/custom_line_service.dart';
import '../../../services/file_service.dart'; import '../../../services/file_service.dart';
/// PDF预览页面 /// PDF预览页面
@@ -37,6 +38,7 @@ class _PdfPreviewPageState extends State<PdfPreviewPage> {
if (urls.isNotEmpty) { if (urls.isNotEmpty) {
final urlData = urls[0] as Map<String, dynamic>; final urlData = urls[0] as Map<String, dynamic>;
final url = urlData['url'] as String; final url = urlData['url'] as String;
await CustomLineService.instance.activateSelectedNodeForUrl(url);
if (mounted) { if (mounted) {
setState(() { setState(() {
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart'; import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart'; import 'package:media_kit_video/media_kit_video.dart';
import '../../../data/models/file_model.dart'; import '../../../data/models/file_model.dart';
import '../../../services/custom_line_service.dart';
import '../../../services/file_service.dart'; import '../../../services/file_service.dart';
import 'widgets/video_controls_overlay.dart'; import 'widgets/video_controls_overlay.dart';
@@ -45,7 +46,11 @@ class _VideoPreviewPageState extends State<VideoPreviewPage> {
if (mounted) { if (mounted) {
setState(() => _isLoading = false); setState(() => _isLoading = false);
player.open(Media(url), play: true); await CustomLineService.instance.configureMediaPlayerProxy(
player,
url,
);
await player.open(Media(url), play: true);
} }
} else { } else {
if (mounted) { if (mounted) {
@@ -78,37 +83,42 @@ class _VideoPreviewPageState extends State<VideoPreviewPage> {
body: _isLoading body: _isLoading
? const Center(child: CircularProgressIndicator(color: Colors.white)) ? const Center(child: CircularProgressIndicator(color: Colors.white))
: _errorMessage != null : _errorMessage != null
? Center( ? Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const Icon(Icons.error_outline, size: 64, color: Colors.white), const Icon(
const SizedBox(height: 16), Icons.error_outline,
Text( size: 64,
_errorMessage!, color: Colors.white,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white70),
),
const SizedBox(height: 16),
FilledButton(
onPressed: () {
setState(() {
_isLoading = true;
_errorMessage = null;
});
_loadVideoUrl();
},
child: const Text('重试'),
),
],
), ),
) const SizedBox(height: 16),
: ExcludeSemantics( Text(
child: Video( _errorMessage!,
controller: controller, textAlign: TextAlign.center,
controls: (state) => VideoControlsOverlay(state: state, title: widget.file.name), style: const TextStyle(color: Colors.white70),
), ),
), const SizedBox(height: 16),
FilledButton(
onPressed: () {
setState(() {
_isLoading = true;
_errorMessage = null;
});
_loadVideoUrl();
},
child: const Text('重试'),
),
],
),
)
: ExcludeSemantics(
child: Video(
controller: controller,
controls: (state) =>
VideoControlsOverlay(state: state, title: widget.file.name),
),
),
); );
} }
} }
@@ -8,6 +8,7 @@ import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import '../../../services/api_service.dart'; import '../../../services/api_service.dart';
import '../../../services/custom_line_service.dart';
import '../../../services/storage_service.dart'; import '../../../services/storage_service.dart';
import '../../providers/auth_provider.dart'; import '../../providers/auth_provider.dart';
import '../../widgets/desktop_constrained.dart'; import '../../widgets/desktop_constrained.dart';
@@ -45,6 +46,7 @@ class _NetworkTestPageState extends State<NetworkTestPage> {
_NetworkDiagnostics? _networkDiagnostics; _NetworkDiagnostics? _networkDiagnostics;
_ServiceDiagnostics? _serviceDiagnostics; _ServiceDiagnostics? _serviceDiagnostics;
_CustomLineDiagnostics? _customDiagnostics; _CustomLineDiagnostics? _customDiagnostics;
String? _selectedCustomNodeId;
String? _networkError; String? _networkError;
String? _serviceError; String? _serviceError;
String? _customError; String? _customError;
@@ -52,6 +54,7 @@ class _NetworkTestPageState extends State<NetworkTestPage> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
unawaited(_loadSelectedCustomNode());
WidgetsBinding.instance.addPostFrameCallback((_) => _runNetworkTest()); WidgetsBinding.instance.addPostFrameCallback((_) => _runNetworkTest());
} }
@@ -122,7 +125,9 @@ class _NetworkTestPageState extends State<NetworkTestPage> {
loading: _customLoading, loading: _customLoading,
error: _customError, error: _customError,
diagnostics: _customDiagnostics, diagnostics: _customDiagnostics,
selectedNodeId: _selectedCustomNodeId,
onRefresh: _runCustomLineTest, onRefresh: _runCustomLineTest,
onSelectNode: _selectCustomNode,
onOpenTelegram: () => _openExternalUrl(_telegramUri), onOpenTelegram: () => _openExternalUrl(_telegramUri),
), ),
}, },
@@ -173,6 +178,43 @@ class _NetworkTestPageState extends State<NetworkTestPage> {
} }
} }
Future<void> _loadSelectedCustomNode() async {
final selected = await CustomLineService.instance.getSelectedNode();
if (!mounted) return;
setState(() => _selectedCustomNodeId = selected?.id);
}
Future<void> _selectCustomNode(_CustomNodeProbe? probe) async {
if (probe == null) {
await CustomLineService.instance.clearSelectedNode();
if (mounted) setState(() => _selectedCustomNodeId = null);
ToastHelper.success('已关闭自定义线路节点');
return;
}
if (probe.successCount <= 0) {
ToastHelper.failure('该节点当前不可用,无法选择');
return;
}
if (!probe.node.canRewriteDownloadUrl) {
ToastHelper.failure('该节点未配置可用于下载加速的 IP 地址');
return;
}
await CustomLineService.instance.setSelectedNode(
probe.node.toSelectedNode(),
);
final endpoint = await CustomLineService.instance
.activateSelectedNodeForHost(probe.node.host);
if (endpoint == null) {
await CustomLineService.instance.clearSelectedNode();
ToastHelper.failure('节点启用失败,请稍后重试');
return;
}
if (mounted) setState(() => _selectedCustomNodeId = probe.node.id);
ToastHelper.success('已选择节点:${probe.node.displayName}');
}
bool _isUserGroup(String groupName) => bool _isUserGroup(String groupName) =>
groupName.trim().toLowerCase() == 'user'; groupName.trim().toLowerCase() == 'user';
@@ -229,9 +271,11 @@ class _NetworkTestPageState extends State<NetworkTestPage> {
final nodes = await _fetchCustomNodes( final nodes = await _fetchCustomNodes(
clientId: clientId, clientId: clientId,
groupName: groupName, groupName: groupName,
userId: auth.user!.id,
); );
final probes = await Future.wait(nodes.map(_testCustomNode)); final probes = await Future.wait(nodes.map(_testCustomNode));
final recommended = _bestCustomNode(probes); final recommended = _bestCustomNode(probes);
final selectedNodeId = await _resolveSelectedCustomNodeId(probes);
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
@@ -240,6 +284,7 @@ class _NetworkTestPageState extends State<NetworkTestPage> {
nodes: probes, nodes: probes,
recommended: recommended, recommended: recommended,
); );
_selectedCustomNodeId = selectedNodeId;
}); });
} catch (e) { } catch (e) {
if (mounted) setState(() => _customError = e.toString()); if (mounted) setState(() => _customError = e.toString());
@@ -248,9 +293,29 @@ class _NetworkTestPageState extends State<NetworkTestPage> {
} }
} }
Future<String?> _resolveSelectedCustomNodeId(
List<_CustomNodeProbe> probes,
) async {
final selectable = probes
.where(
(probe) => probe.successCount > 0 && probe.node.canRewriteDownloadUrl,
)
.toList();
final current = await CustomLineService.instance.getSelectedNode();
if (current != null &&
selectable.any((probe) => probe.node.id == current.id)) {
return current.id;
}
await CustomLineService.instance.clearSelectedNode();
return null;
}
Future<List<_CustomNode>> _fetchCustomNodes({ Future<List<_CustomNode>> _fetchCustomNodes({
required String clientId, required String clientId,
required String groupName, required String groupName,
required String userId,
}) async { }) async {
final handshakeResponse = await http final handshakeResponse = await http
.post( .post(
@@ -277,7 +342,10 @@ class _NetworkTestPageState extends State<NetworkTestPage> {
final nodesResponse = await http final nodesResponse = await http
.get( .get(
_ipNodeApiUri('/api/v1/nodes', queryParameters: {'group': groupName}), _ipNodeApiUri(
'/api/v1/nodes',
queryParameters: {'group': groupName, 'user_id': userId},
),
headers: { headers: {
'Accept': 'application/json', 'Accept': 'application/json',
'X-Client-UUID': clientId, 'X-Client-UUID': clientId,
@@ -834,7 +902,9 @@ class _CustomLinePanel extends StatelessWidget {
final bool loading; final bool loading;
final String? error; final String? error;
final _CustomLineDiagnostics? diagnostics; final _CustomLineDiagnostics? diagnostics;
final String? selectedNodeId;
final VoidCallback onRefresh; final VoidCallback onRefresh;
final ValueChanged<_CustomNodeProbe?> onSelectNode;
final VoidCallback onOpenTelegram; final VoidCallback onOpenTelegram;
const _CustomLinePanel({ const _CustomLinePanel({
@@ -842,7 +912,9 @@ class _CustomLinePanel extends StatelessWidget {
required this.loading, required this.loading,
required this.error, required this.error,
required this.diagnostics, required this.diagnostics,
required this.selectedNodeId,
required this.onRefresh, required this.onRefresh,
required this.onSelectNode,
required this.onOpenTelegram, required this.onOpenTelegram,
}); });
@@ -868,11 +940,21 @@ class _CustomLinePanel extends StatelessWidget {
value: recommended.node.displayName, value: recommended.node.displayName,
valueColor: Colors.green, valueColor: Colors.green,
), ),
_InfoRow(label: '节点地址', value: recommended.node.endpointLabel),
_InfoRow(label: '地区', value: recommended.node.locationLabel),
_InfoRow(label: '运营商', value: recommended.node.carrierLabel),
_InfoRow(label: '平均延迟', value: recommended.latencyLabel), _InfoRow(label: '平均延迟', value: recommended.latencyLabel),
_InfoRow(label: '丢包率', value: recommended.lossLabel), _InfoRow(label: '丢包率', value: recommended.lossLabel),
_InfoRow(label: '样本', value: recommended.sampleLabel),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed:
recommended.successCount > 0 &&
recommended.node.canRewriteDownloadUrl
? () => onSelectNode(recommended)
: null,
icon: const Icon(Icons.check_circle_outline),
label: const Text('使用推荐节点'),
),
),
] else if (!loading && data != null) ...[ ] else if (!loading && data != null) ...[
const _DescriptionLine('暂无可推荐节点,请刷新后重试或联系官方支持。'), const _DescriptionLine('暂无可推荐节点,请刷新后重试或联系官方支持。'),
] else if (!loading && error == null) ...[ ] else if (!loading && error == null) ...[
@@ -880,6 +962,24 @@ class _CustomLinePanel extends StatelessWidget {
], ],
], ],
), ),
if (data != null && selectedNodeId != null)
_SectionCard(
title: '当前选择',
children: [
_InfoRow(
label: '节点',
value: data.selectedNodeName(selectedNodeId!) ?? '已选择节点',
),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: () => onSelectNode(null),
icon: const Icon(Icons.close),
label: const Text('关闭自定义线路'),
),
),
],
),
_SectionCard(title: '可选节点', children: _availableNodeChildren(data)), _SectionCard(title: '可选节点', children: _availableNodeChildren(data)),
_SectionCard( _SectionCard(
title: '自定义节点', title: '自定义节点',
@@ -923,7 +1023,11 @@ class _CustomLinePanel extends StatelessWidget {
return [ return [
for (var i = 0; i < data.nodes.length; i++) ...[ for (var i = 0; i < data.nodes.length; i++) ...[
if (i > 0) const Divider(height: 18), if (i > 0) const Divider(height: 18),
_CustomNodeTile(probe: data.nodes[i]), _CustomNodeTile(
probe: data.nodes[i],
selected: data.nodes[i].node.id == selectedNodeId,
onSelect: () => onSelectNode(data.nodes[i]),
),
], ],
]; ];
} }
@@ -931,48 +1035,106 @@ class _CustomLinePanel extends StatelessWidget {
class _CustomNodeTile extends StatelessWidget { class _CustomNodeTile extends StatelessWidget {
final _CustomNodeProbe probe; final _CustomNodeProbe probe;
final bool selected;
final VoidCallback onSelect;
const _CustomNodeTile({required this.probe}); const _CustomNodeTile({
required this.probe,
required this.selected,
required this.onSelect,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final available = probe.successCount > 0; final available = probe.successCount > 0;
final statusColor = _statusColor(context, available); final statusColor = _statusColor(context, available);
return Padding( final selectable = available && probe.node.canRewriteDownloadUrl;
padding: const EdgeInsets.symmetric(vertical: 4), return InkWell(
child: Column( onTap: selectable ? onSelect : null,
crossAxisAlignment: CrossAxisAlignment.start, borderRadius: BorderRadius.circular(8),
children: [ child: Padding(
Row( padding: const EdgeInsets.symmetric(vertical: 6),
crossAxisAlignment: CrossAxisAlignment.start, child: Row(
children: [ crossAxisAlignment: CrossAxisAlignment.start,
Expanded( children: [
child: Text( Icon(
probe.node.displayName, selected
style: Theme.of( ? Icons.radio_button_checked
context, : Icons.radio_button_unchecked,
).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w700), color: selected ? Colors.green : statusColor,
), ),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Wrap(
spacing: 8,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(
probe.node.displayName,
style: Theme.of(context).textTheme.bodyLarge
?.copyWith(fontWeight: FontWeight.w700),
),
if (probe.node.dedicated)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 7,
vertical: 2,
),
decoration: BoxDecoration(
color: const Color(0xFFFFF3D6),
borderRadius: BorderRadius.circular(999),
border: Border.all(
color: const Color(0xFFE5B454),
),
),
child: const Text(
'专线',
style: TextStyle(
color: Color(0xFF8A5A00),
fontSize: 12,
fontWeight: FontWeight.w800,
),
),
),
],
),
),
const SizedBox(width: 12),
Text(
available ? '可用' : '不可用',
style: TextStyle(
color: statusColor,
fontWeight: FontWeight.w700,
),
),
],
),
const SizedBox(height: 4),
Text(
'平均延迟 ${probe.latencyLabel} / 丢包率 ${probe.lossLabel} / 样本 ${probe.sampleLabel}',
),
if (available && !probe.node.canRewriteDownloadUrl) ...[
const SizedBox(height: 4),
Text(
'未配置可用于下载加速的 IP',
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
),
],
],
), ),
const SizedBox(width: 12), ),
Text( ],
available ? '可用' : '不可用', ),
style: TextStyle(
color: statusColor,
fontWeight: FontWeight.w700,
),
),
],
),
const SizedBox(height: 4),
Text(probe.node.endpointLabel),
const SizedBox(height: 4),
Text('${probe.node.locationLabel} / ${probe.node.carrierLabel}'),
const SizedBox(height: 4),
Text(
'平均延迟 ${probe.latencyLabel} / 丢包率 ${probe.lossLabel} / 样本 ${probe.sampleLabel}',
),
],
), ),
); );
} }
@@ -1125,6 +1287,13 @@ class _CustomLineDiagnostics {
required this.nodes, required this.nodes,
required this.recommended, required this.recommended,
}); });
String? selectedNodeName(String nodeId) {
for (final probe in nodes) {
if (probe.node.id == nodeId) return probe.node.displayName;
}
return null;
}
} }
class _CustomNode { class _CustomNode {
@@ -1139,6 +1308,7 @@ class _CustomNode {
final String province; final String province;
final String carrier; final String carrier;
final List<String> groups; final List<String> groups;
final bool dedicated;
final bool enabled; final bool enabled;
final String probeUrl; final String probeUrl;
@@ -1154,6 +1324,7 @@ class _CustomNode {
required this.province, required this.province,
required this.carrier, required this.carrier,
required this.groups, required this.groups,
required this.dedicated,
required this.enabled, required this.enabled,
required this.probeUrl, required this.probeUrl,
}); });
@@ -1171,6 +1342,7 @@ class _CustomNode {
province: json['province']?.toString() ?? '', province: json['province']?.toString() ?? '',
carrier: json['carrier']?.toString() ?? '', carrier: json['carrier']?.toString() ?? '',
groups: _asStringList(json['groups']), groups: _asStringList(json['groups']),
dedicated: json['dedicated'] == true,
enabled: json['enabled'] != false, enabled: json['enabled'] != false,
probeUrl: json['probe_url']?.toString() ?? '', probeUrl: json['probe_url']?.toString() ?? '',
); );
@@ -1189,6 +1361,20 @@ class _CustomNode {
return ip.trim(); return ip.trim();
} }
bool get canRewriteDownloadUrl =>
ip.trim().isNotEmpty && host.trim().isNotEmpty;
SelectedCustomLineNode toSelectedNode() {
return SelectedCustomLineNode(
id: id,
name: displayName,
ip: ip.trim(),
host: host.trim(),
protocol: protocol.trim(),
port: port,
);
}
String get endpointLabel { String get endpointLabel {
final endpoint = endpointHost; final endpoint = endpointHost;
if (endpoint.isEmpty) return '未配置地址'; if (endpoint.isEmpty) return '未配置地址';
+223
View File
@@ -0,0 +1,223 @@
import 'dart:convert';
import 'package:media_kit/media_kit.dart';
import '../core/constants/storage_keys.dart';
import '../core/utils/app_logger.dart';
import 'host_mapping_proxy_service.dart';
import 'server_service.dart';
import 'storage_service.dart';
class CustomLineService {
CustomLineService._();
static final CustomLineService instance = CustomLineService._();
HostMappingProxyEndpoint? get currentProxyEndpoint =>
HostMappingProxyService.instance.endpoint;
Future<SelectedCustomLineNode?> getSelectedNode() async {
final raw = await StorageService.instance.getString(
StorageKeys.selectedCustomLineNode,
);
if (raw == null || raw.isEmpty) return null;
try {
final decoded = jsonDecode(raw);
if (decoded is! Map<String, dynamic>) return null;
final node = SelectedCustomLineNode.fromJson(decoded);
if (!node.canRewriteDownloadUrl) return null;
return node;
} catch (_) {
return null;
}
}
Future<void> setSelectedNode(SelectedCustomLineNode node) async {
if (!node.canRewriteDownloadUrl) {
throw ArgumentError('The selected custom line node has no usable IP.');
}
await StorageService.instance.setString(
StorageKeys.selectedCustomLineNode,
jsonEncode(node.toJson()),
);
}
Future<void> clearSelectedNode() async {
await StorageService.instance.remove(StorageKeys.selectedCustomLineNode);
await HostMappingProxyService.instance.stop();
}
Future<CustomLineDownloadRoute> routeDownloadUrl(String url) async {
if (!_canUseCustomLine()) {
await HostMappingProxyService.instance.stop();
return CustomLineDownloadRoute(url: url);
}
final node = await getSelectedNode();
if (node == null) return CustomLineDownloadRoute(url: url);
final uri = Uri.tryParse(url);
if (uri == null || uri.host.isEmpty) {
return CustomLineDownloadRoute(url: url);
}
final endpoint = await activateSelectedNodeForHost(uri.host);
if (endpoint == null) {
return CustomLineDownloadRoute(url: url);
}
return CustomLineDownloadRoute(
url: url,
nodeName: node.name,
originalHost: uri.host,
proxy: endpoint,
);
}
Future<HostMappingProxyEndpoint?> activateSelectedNodeForHost(String host) {
return activateSelectedNodeForHosts([host]);
}
Future<HostMappingProxyEndpoint?> activateSelectedNodeForHosts(
Iterable<String> hosts,
) async {
if (!_canUseCustomLine()) {
await HostMappingProxyService.instance.stop();
return null;
}
final node = await getSelectedNode();
if (node == null) {
await HostMappingProxyService.instance.stop();
return null;
}
final hostMap = <String, String>{};
void addHost(String value) {
final host = value.trim();
if (host.isNotEmpty) hostMap[host] = node.ip;
}
addHost(node.host);
for (final host in hosts) {
addHost(host);
}
if (hostMap.isEmpty) {
await HostMappingProxyService.instance.stop();
return null;
}
return HostMappingProxyService.instance.configure(hostMap);
}
Future<HostMappingProxyEndpoint?> activateSelectedNodeForUrl(String url) {
final uri = Uri.tryParse(url);
return activateSelectedNodeForHost(uri?.host ?? '');
}
Future<HostMappingProxyEndpoint?> activateSelectedNodeForUrls(
Iterable<String> urls,
) {
final hosts = urls
.map((url) => Uri.tryParse(url)?.host ?? '')
.where((host) => host.trim().isNotEmpty);
return activateSelectedNodeForHosts(hosts);
}
Future<void> configureMediaPlayerProxy(Player player, String url) async {
final endpoint = await activateSelectedNodeForUrl(url);
final platform = player.platform;
if (platform is! NativePlayer) return;
try {
await platform.setProperty('http-proxy', endpoint?.proxyUri ?? '');
AppLogger.d('媒体播放器自定义线路代理已配置: ${endpoint?.proxyUri ?? 'DIRECT'}');
} catch (e) {
AppLogger.d('媒体播放器自定义线路代理配置失败: $e');
}
}
bool _canUseCustomLine() {
final user = ServerService.instance.currentServer?.user;
final groupName = user?.group?.name.trim().toLowerCase();
return user != null &&
groupName != null &&
groupName.isNotEmpty &&
groupName != 'user';
}
}
class SelectedCustomLineNode {
final String id;
final String name;
final String ip;
final String host;
final String protocol;
final int? port;
const SelectedCustomLineNode({
required this.id,
required this.name,
required this.ip,
required this.host,
this.protocol = '',
this.port,
});
bool get canRewriteDownloadUrl =>
ip.trim().isNotEmpty && host.trim().isNotEmpty;
String routeScheme(String fallback) {
final value = protocol.trim().toLowerCase();
return value == 'http' || value == 'https' ? value : fallback;
}
int? get routePort => port != null && port! > 0 ? port : null;
factory SelectedCustomLineNode.fromJson(Map<String, dynamic> json) {
return SelectedCustomLineNode(
id: json['id']?.toString() ?? '',
name: json['name']?.toString() ?? '',
ip: json['ip']?.toString() ?? '',
host: json['host']?.toString() ?? '',
protocol: json['protocol']?.toString() ?? '',
port: _asNullableInt(json['port']),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'ip': ip,
'host': host,
if (protocol.trim().isNotEmpty) 'protocol': protocol,
if (routePort != null) 'port': routePort,
};
}
static int? _asNullableInt(Object? value) {
if (value is int) return value;
if (value is num) return value.round();
return int.tryParse(value?.toString() ?? '');
}
}
class CustomLineDownloadRoute {
final String url;
final Map<String, String> headers;
final String? nodeName;
final String? originalHost;
final HostMappingProxyEndpoint? proxy;
const CustomLineDownloadRoute({
required this.url,
this.headers = const {},
this.nodeName,
this.originalHost,
this.proxy,
});
bool get changed => proxy != null || headers.isNotEmpty;
}
+32 -4
View File
@@ -7,6 +7,7 @@ import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import '../core/constants/storage_keys.dart'; import '../core/constants/storage_keys.dart';
import '../data/models/download_task_model.dart'; import '../data/models/download_task_model.dart';
import 'custom_line_service.dart';
import 'file_service.dart'; import 'file_service.dart';
import 'storage_service.dart'; import 'storage_service.dart';
import '../core/utils/app_logger.dart'; import '../core/utils/app_logger.dart';
@@ -261,7 +262,14 @@ class DownloadService {
await file.delete(); await file.delete();
} }
return _startBdDownload(task, url, dir); final route = await CustomLineService.instance.routeDownloadUrl(url);
if (route.changed) {
AppLogger.d(
'自定义线路下载代理已应用: node=${route.nodeName}, host=${route.originalHost}, proxy=${route.proxy?.proxyUri}',
);
}
return _startBdDownload(task, route.url, dir, headers: route.headers);
} catch (e) { } catch (e) {
AppLogger.d('下载失败: $e'); AppLogger.d('下载失败: $e');
rethrow; rethrow;
@@ -272,12 +280,15 @@ class DownloadService {
Future<String?> _startBdDownload( Future<String?> _startBdDownload(
DownloadTaskModel task, DownloadTaskModel task,
String url, String url,
Directory dir, Directory dir, {
) async { Map<String, String> headers = const {},
}) async {
await _configureCustomLineProxy();
final wifiOnly = await isWifiOnlyEnabled(); final wifiOnly = await isWifiOnlyEnabled();
final retries = await getRetries(); final retries = await getRetries();
final bdTask = bd.DownloadTask( final bdTask = bd.DownloadTask(
url: url, url: url,
headers: headers,
filename: task.fileName, filename: task.fileName,
directory: dir.path, directory: dir.path,
baseDirectory: bd.BaseDirectory.root, baseDirectory: bd.BaseDirectory.root,
@@ -340,10 +351,19 @@ class DownloadService {
} }
// 恢复任务:不删除部分文件,使用 resume 方式重建 bdTask // 恢复任务:不删除部分文件,使用 resume 方式重建 bdTask
final route = await CustomLineService.instance.routeDownloadUrl(url);
if (route.changed) {
AppLogger.d(
'自定义线路下载代理已应用: node=${route.nodeName}, host=${route.originalHost}, proxy=${route.proxy?.proxyUri}',
);
}
await _configureCustomLineProxy();
final wifiOnly = await isWifiOnlyEnabled(); final wifiOnly = await isWifiOnlyEnabled();
final retries = await getRetries(); final retries = await getRetries();
final bdTask = bd.DownloadTask( final bdTask = bd.DownloadTask(
url: url, url: route.url,
headers: route.headers,
filename: task.fileName, filename: task.fileName,
directory: dir.path, directory: dir.path,
baseDirectory: bd.BaseDirectory.root, baseDirectory: bd.BaseDirectory.root,
@@ -382,6 +402,14 @@ class DownloadService {
} }
} }
Future<void> _configureCustomLineProxy() async {
final endpoint = CustomLineService.instance.currentProxyEndpoint;
final config = endpoint == null ? false : (endpoint.host, endpoint.port);
await bd.FileDownloader().configure(
globalConfig: (bd.Config.proxy, config),
);
}
/// 暂停下载 /// 暂停下载
Future<void> pauseDownload(String taskId) async { Future<void> pauseDownload(String taskId) async {
final bdTask = _bdTasks[taskId]; final bdTask = _bdTasks[taskId];
+16
View File
@@ -1,6 +1,7 @@
import 'api_service.dart'; import 'api_service.dart';
import '../core/utils/app_logger.dart'; import '../core/utils/app_logger.dart';
import '../core/utils/file_utils.dart'; import '../core/utils/file_utils.dart';
import 'custom_line_service.dart';
/// 文件服务 /// 文件服务
class FileService { class FileService {
@@ -156,10 +157,25 @@ class FileService {
data: data, data: data,
headers: headers, headers: headers,
); );
await _activateCustomLineForUrls(response);
return response; return response;
} }
Future<void> _activateCustomLineForUrls(Map<String, dynamic> response) async {
final urls = response['urls'];
if (urls is! List) return;
final resolvedUrls = <String>[];
for (final item in urls) {
if (item is! Map) continue;
final url = item['url']?.toString();
if (url == null || url.isEmpty) continue;
resolvedUrls.add(url);
}
await CustomLineService.instance.activateSelectedNodeForUrls(resolvedUrls);
}
/// 创建直接链接(分享链接) /// 创建直接链接(分享链接)
Future<List<Map<String, dynamic>>> createDirectLinks({ Future<List<Map<String, dynamic>>> createDirectLinks({
required List<String> uris, required List<String> uris,
@@ -0,0 +1,314 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import '../core/utils/app_logger.dart';
class HostMappingProxyService {
HostMappingProxyService._();
static final HostMappingProxyService instance = HostMappingProxyService._();
ServerSocket? _server;
HostMappingProxyEndpoint? _endpoint;
Map<String, String> _hostMap = const {};
HostMappingProxyEndpoint? get endpoint => _hostMap.isEmpty ? null : _endpoint;
Future<HostMappingProxyEndpoint?> configure(
Map<String, String> hostMap,
) async {
final cleaned = <String, String>{};
for (final entry in hostMap.entries) {
final host = _normalizeHost(entry.key);
final ip = entry.value.trim();
if (host.isNotEmpty && ip.isNotEmpty) {
cleaned[host] = ip;
}
}
if (cleaned.isEmpty) {
await stop();
return null;
}
_hostMap = cleaned;
if (_server == null) {
_server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
_endpoint = HostMappingProxyEndpoint(
host: InternetAddress.loopbackIPv4.address,
port: _server!.port,
);
_server!.listen(
_handleClient,
onError: (e) {
AppLogger.d('自定义线路本地代理监听错误: $e');
},
);
AppLogger.d('自定义线路本地代理已启动: ${_endpoint!.proxyUri}');
}
return _endpoint;
}
Future<void> stop() async {
_hostMap = const {};
final server = _server;
_server = null;
_endpoint = null;
if (server != null) {
await server.close();
AppLogger.d('自定义线路本地代理已关闭');
}
}
void _handleClient(Socket client) {
unawaited(_serveClient(client));
}
Future<void> _serveClient(Socket client) async {
Socket? remote;
StreamController<List<int>>? rest;
try {
client.setOption(SocketOption.tcpNoDelay, true);
final initial = await _readInitialRequest(client);
rest = initial.rest;
final lines = latin1.decode(initial.header).split('\r\n');
if (lines.isEmpty || lines.first.trim().isEmpty) {
throw const FormatException('empty proxy request');
}
final first = lines.first.split(' ');
if (first.length < 3) {
throw FormatException('invalid proxy request: ${lines.first}');
}
final method = first[0].toUpperCase();
if (method == 'CONNECT') {
final target = _splitAuthority(first[1], 443);
remote = await _connectMapped(target.host, target.port);
client.add(utf8.encode('HTTP/1.1 200 Connection Established\r\n\r\n'));
await client.flush();
} else {
final target = _httpTarget(first[1], lines);
remote = await _connectMapped(target.host, target.port);
remote.add(_rewriteHttpHeader(initial.header, target.requestTarget));
}
remote.setOption(SocketOption.tcpNoDelay, true);
unawaited(rest.stream.pipe(remote).catchError((_) {}));
unawaited(remote.cast<List<int>>().pipe(client).catchError((_) {}));
} catch (e) {
AppLogger.d('自定义线路本地代理请求失败: $e');
try {
client.add(utf8.encode('HTTP/1.1 502 Bad Gateway\r\n\r\n'));
await client.flush();
} catch (_) {}
await rest?.close();
remote?.destroy();
client.destroy();
}
}
Future<_ProxyInitialRequest> _readInitialRequest(Socket socket) {
final completer = Completer<_ProxyInitialRequest>();
final builder = BytesBuilder(copy: false);
final rest = StreamController<List<int>>(sync: true);
late StreamSubscription<Uint8List> subscription;
subscription = socket.listen(
(chunk) {
if (completer.isCompleted) {
rest.add(chunk);
return;
}
builder.add(chunk);
final bytes = builder.toBytes();
final end = _headerEnd(bytes);
if (end >= 0) {
final headerEnd = end + 4;
completer.complete(
_ProxyInitialRequest(
header: bytes.sublist(0, headerEnd),
rest: rest,
),
);
if (bytes.length > headerEnd) {
rest.add(bytes.sublist(headerEnd));
}
} else if (bytes.length > 64 * 1024) {
completer.completeError(
const FormatException('proxy request header too large'),
);
unawaited(subscription.cancel());
unawaited(rest.close());
}
},
onError: (Object error, StackTrace stackTrace) {
if (!completer.isCompleted) {
completer.completeError(error, stackTrace);
} else {
rest.addError(error, stackTrace);
}
},
onDone: () {
if (!completer.isCompleted) {
completer.completeError(const SocketException('proxy client closed'));
}
unawaited(rest.close());
},
cancelOnError: false,
);
return completer.future;
}
int _headerEnd(List<int> bytes) {
for (var i = 0; i <= bytes.length - 4; i++) {
if (bytes[i] == 13 &&
bytes[i + 1] == 10 &&
bytes[i + 2] == 13 &&
bytes[i + 3] == 10) {
return i;
}
}
return -1;
}
Future<Socket> _connectMapped(String host, int port) {
final normalized = _normalizeHost(host);
final targetHost = _hostMap[normalized] ?? host;
return Socket.connect(
targetHost,
port,
timeout: const Duration(seconds: 12),
);
}
_ProxyTarget _httpTarget(String target, List<String> lines) {
final uri = Uri.tryParse(target);
if (uri != null && uri.hasScheme && uri.host.isNotEmpty) {
return _ProxyTarget(
host: uri.host,
port: uri.hasPort ? uri.port : (uri.scheme == 'https' ? 443 : 80),
requestTarget: _originForm(uri),
);
}
final hostHeader = _headerValue(lines, 'Host');
final authority = _splitAuthority(hostHeader, 80);
return _ProxyTarget(
host: authority.host,
port: authority.port,
requestTarget: target,
);
}
List<int> _rewriteHttpHeader(List<int> header, String requestTarget) {
final text = latin1.decode(header);
final lines = text.split('\r\n');
final first = lines.first.split(' ');
first[1] = requestTarget.isEmpty ? '/' : requestTarget;
lines[0] = first.join(' ');
final filtered = lines
.where((line) => !line.toLowerCase().startsWith('proxy-connection:'))
.join('\r\n');
return latin1.encode(filtered);
}
String _originForm(Uri uri) {
final path = uri.path.isEmpty ? '/' : uri.path;
return uri.hasQuery ? '$path?${uri.query}' : path;
}
String _headerValue(List<String> lines, String name) {
final prefix = '${name.toLowerCase()}:';
for (final line in lines.skip(1)) {
if (line.toLowerCase().startsWith(prefix)) {
return line.substring(line.indexOf(':') + 1).trim();
}
}
throw FormatException('missing $name header');
}
_ProxyAuthority _splitAuthority(String value, int defaultPort) {
final authority = value.trim();
if (authority.startsWith('[')) {
final end = authority.indexOf(']');
if (end > 0) {
final host = authority.substring(1, end);
final port = authority.length > end + 2
? int.tryParse(authority.substring(end + 2)) ?? defaultPort
: defaultPort;
return _ProxyAuthority(host, port);
}
}
final colon = authority.lastIndexOf(':');
if (colon > 0 && colon < authority.length - 1) {
final port = int.tryParse(authority.substring(colon + 1));
if (port != null) {
return _ProxyAuthority(authority.substring(0, colon), port);
}
}
return _ProxyAuthority(authority, defaultPort);
}
String _normalizeHost(String host) {
final value = host.trim();
final uri = Uri.tryParse(value);
final resolved = uri != null && uri.hasScheme && uri.host.isNotEmpty
? uri.host
: value;
return resolved.toLowerCase().replaceAll(RegExp(r'\.$'), '');
}
}
class HostMappingProxyEndpoint {
final String host;
final int port;
const HostMappingProxyEndpoint({required this.host, required this.port});
String get proxyUri => 'http://$host:$port';
}
class HostMappingHttpOverrides extends HttpOverrides {
@override
HttpClient createHttpClient(SecurityContext? context) {
final client = super.createHttpClient(context);
client.findProxy = (_) {
final endpoint = HostMappingProxyService.instance.endpoint;
if (endpoint == null) return 'DIRECT';
return 'PROXY ${endpoint.host}:${endpoint.port}; DIRECT';
};
return client;
}
}
class _ProxyInitialRequest {
final Uint8List header;
final StreamController<List<int>> rest;
const _ProxyInitialRequest({required this.header, required this.rest});
}
class _ProxyAuthority {
final String host;
final int port;
const _ProxyAuthority(this.host, this.port);
}
class _ProxyTarget {
final String host;
final int port;
final String requestTarget;
const _ProxyTarget({
required this.host,
required this.port,
required this.requestTarget,
});
}
+21 -26
View File
@@ -45,12 +45,7 @@ class ServerService {
} catch (e) { } catch (e) {
AppLogger.d('加载服务器列表失败: $e'); AppLogger.d('加载服务器列表失败: $e');
// 加载失败时使用默认服务器 // 加载失败时使用默认服务器
_servers = [ _servers = [ServerModel(label: _defaultLabel, baseUrl: _defaultBaseUrl)];
ServerModel(
label: _defaultLabel,
baseUrl: _defaultBaseUrl,
),
];
_currentServer = _servers.first; _currentServer = _servers.first;
} }
} }
@@ -65,15 +60,10 @@ class ServerService {
} }
} }
_currentServer = (savedDefaultServer ?? _currentServer =
ServerModel( (savedDefaultServer ??
label: _defaultLabel, ServerModel(label: _defaultLabel, baseUrl: _defaultBaseUrl))
baseUrl: _defaultBaseUrl, .copyWith(label: _defaultLabel, baseUrl: _defaultBaseUrl);
))
.copyWith(
label: _defaultLabel,
baseUrl: _defaultBaseUrl,
);
_servers = [_currentServer!]; _servers = [_currentServer!];
} }
@@ -91,7 +81,9 @@ class ServerService {
/// 保存上次选中的服务器 /// 保存上次选中的服务器
Future<void> _saveLastSelected() async { Future<void> _saveLastSelected() async {
if (_currentServer != null) { if (_currentServer != null) {
await StorageService.instance.setLastSelectedServerLabel(_currentServer!.label); await StorageService.instance.setLastSelectedServerLabel(
_currentServer!.label,
);
} }
} }
@@ -125,16 +117,21 @@ class ServerService {
String? password, String? password,
UserModel? user, UserModel? user,
bool? rememberMe, bool? rememberMe,
bool clearEmail = false,
bool clearPassword = false,
bool clearUser = false,
}) async { }) async {
if (_currentServer == null) { if (_currentServer == null) {
throw Exception('没有选中的服务器'); throw Exception('没有选中的服务器');
} }
_currentServer = _currentServer!.copyWith( _currentServer = ServerModel(
email: email, label: _currentServer!.label,
password: password, baseUrl: _currentServer!.baseUrl,
user: user,
rememberMe: rememberMe ?? _currentServer!.rememberMe, rememberMe: rememberMe ?? _currentServer!.rememberMe,
email: clearEmail ? null : (email ?? _currentServer!.email),
password: clearPassword ? null : (password ?? _currentServer!.password),
user: clearUser ? null : (user ?? _currentServer!.user),
); );
// 更新列表中的引用 // 更新列表中的引用
@@ -152,17 +149,15 @@ class ServerService {
email: null, email: null,
password: null, password: null,
user: null, user: null,
clearEmail: true,
clearPassword: true,
clearUser: true,
); );
} }
/// 重置为默认服务器列表 /// 重置为默认服务器列表
Future<void> resetToDefault() async { Future<void> resetToDefault() async {
_servers = [ _servers = [ServerModel(label: _defaultLabel, baseUrl: _defaultBaseUrl)];
ServerModel(
label: _defaultLabel,
baseUrl: _defaultBaseUrl,
),
];
_currentServer = _servers.first; _currentServer = _servers.first;
await _saveServers(); await _saveServers();
await _saveLastSelected(); await _saveLastSelected();
+15 -3
View File
@@ -2,6 +2,7 @@ import 'api_service.dart';
import '../core/utils/app_logger.dart'; import '../core/utils/app_logger.dart';
import '../core/utils/file_utils.dart'; import '../core/utils/file_utils.dart';
import '../core/utils/time_flow_decoder.dart'; import '../core/utils/time_flow_decoder.dart';
import 'custom_line_service.dart';
/// 缩略图缓存条目 /// 缩略图缓存条目
class _ThumbCacheEntry { class _ThumbCacheEntry {
@@ -41,6 +42,9 @@ class ThumbnailService {
// 1. 检查内存缓存 // 1. 检查内存缓存
final cached = _urlCache[cacheKey]; final cached = _urlCache[cacheKey];
if (cached != null && !cached.isExpired) { if (cached != null && !cached.isExpired) {
await CustomLineService.instance.activateSelectedNodeForUrl(
cached.imageUrl,
);
return cached.imageUrl; return cached.imageUrl;
} }
if (cached != null) { if (cached != null) {
@@ -64,7 +68,10 @@ class ThumbnailService {
} }
} }
Future<String?> _fetchThumbnailUrl(String fileUri, String? contextHint) async { Future<String?> _fetchThumbnailUrl(
String fileUri,
String? contextHint,
) async {
try { try {
final uri = FileUtils.toCloudreveUri(fileUri); final uri = FileUtils.toCloudreveUri(fileUri);
final headers = contextHint != null final headers = contextHint != null
@@ -97,12 +104,15 @@ class ThumbnailService {
if (url.isEmpty) return null; if (url.isEmpty) return null;
AppLogger.d('ThumbnailService: resolved URL for $fileUri = $url'); AppLogger.d('ThumbnailService: resolved URL for $fileUri = $url');
await CustomLineService.instance.activateSelectedNodeForUrl(url);
// 解析过期时间,缓存提前 30 秒过期 // 解析过期时间,缓存提前 30 秒过期
DateTime expiresAt; DateTime expiresAt;
if (expiresStr != null) { if (expiresStr != null) {
try { try {
expiresAt = DateTime.parse(expiresStr).subtract(const Duration(seconds: 30)); expiresAt = DateTime.parse(
expiresStr,
).subtract(const Duration(seconds: 30));
} catch (_) { } catch (_) {
expiresAt = DateTime.now().add(const Duration(minutes: 5)); expiresAt = DateTime.now().add(const Duration(minutes: 5));
} }
@@ -118,7 +128,9 @@ class ThumbnailService {
return url; return url;
} catch (e) { } catch (e) {
AppLogger.d('ThumbnailService: failed to get thumbnail URL for $fileUri: $e'); AppLogger.d(
'ThumbnailService: failed to get thumbnail URL for $fileUri: $e',
);
return null; return null;
} }
} }
+143
View File
@@ -0,0 +1,143 @@
import 'package:cloudreve4_flutter/data/models/user_model.dart';
import 'package:cloudreve4_flutter/services/custom_line_service.dart';
import 'package:cloudreve4_flutter/services/server_service.dart';
import 'package:cloudreve4_flutter/services/storage_service.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
test('selected node json round trip preserves rewrite fields', () {
const node = SelectedCustomLineNode(
id: 'node-1',
name: 'HK 01',
ip: '203.0.113.10',
host: 'node.example.com',
protocol: 'https',
port: 443,
);
final restored = SelectedCustomLineNode.fromJson(node.toJson());
expect(restored.id, 'node-1');
expect(restored.name, 'HK 01');
expect(restored.ip, '203.0.113.10');
expect(restored.host, 'node.example.com');
expect(restored.protocol, 'https');
expect(restored.port, 443);
expect(restored.canRewriteDownloadUrl, isTrue);
});
test('download route defaults to unchanged', () {
const route = CustomLineDownloadRoute(url: 'https://pan.example.com/file');
expect(route.changed, isFalse);
expect(route.headers, isEmpty);
});
test(
'routeDownloadUrl keeps url unchanged and enables host mapping proxy',
() async {
SharedPreferences.setMockInitialValues({});
await ServerService.instance.init();
await ServerService.instance.updateCurrentServerLogin(
user: UserModel(
id: 'user-1',
nickname: 'VIP',
createdAt: DateTime(2026, 1),
group: GroupModel(id: 'vip', name: 'VIP'),
),
);
await CustomLineService.instance.setSelectedNode(
const SelectedCustomLineNode(
id: 'node-1',
name: 'HK 01',
ip: '203.0.113.10',
host: 'node.example.com',
),
);
final route = await CustomLineService.instance.routeDownloadUrl(
'https://storage.example.com:8443/download/file.txt?token=abc',
);
expect(
route.url,
'https://storage.example.com:8443/download/file.txt?token=abc',
);
expect(route.headers, isEmpty);
expect(route.changed, isTrue);
expect(route.originalHost, 'storage.example.com');
expect(route.proxy, isNotNull);
expect(route.proxy!.host, '127.0.0.1');
expect(route.proxy!.port, greaterThan(0));
await CustomLineService.instance.clearSelectedNode();
await StorageService.instance.clear();
},
);
test('routeDownloadUrl stays unchanged for User group', () async {
SharedPreferences.setMockInitialValues({});
await ServerService.instance.init();
await ServerService.instance.updateCurrentServerLogin(
user: UserModel(
id: 'user-2',
nickname: 'Normal',
createdAt: DateTime(2026, 1),
group: GroupModel(id: 'user', name: 'User'),
),
);
await CustomLineService.instance.setSelectedNode(
const SelectedCustomLineNode(
id: 'node-1',
name: 'HK 01',
ip: '203.0.113.10',
host: 'node.example.com',
),
);
final route = await CustomLineService.instance.routeDownloadUrl(
'https://storage.example.com/download/file.txt',
);
expect(route.url, 'https://storage.example.com/download/file.txt');
expect(route.headers, isEmpty);
expect(route.changed, isFalse);
await CustomLineService.instance.clearSelectedNode();
await StorageService.instance.clear();
});
test('routeDownloadUrl stays unchanged after login is cleared', () async {
SharedPreferences.setMockInitialValues({});
await ServerService.instance.init();
await ServerService.instance.updateCurrentServerLogin(
user: UserModel(
id: 'user-3',
nickname: 'VIP',
createdAt: DateTime(2026, 1),
group: GroupModel(id: 'vip', name: 'VIP'),
),
);
await CustomLineService.instance.setSelectedNode(
const SelectedCustomLineNode(
id: 'node-1',
name: 'HK 01',
ip: '203.0.113.10',
host: 'node.example.com',
),
);
await ServerService.instance.clearCurrentServerLogin();
final route = await CustomLineService.instance.routeDownloadUrl(
'https://storage.example.com/download/file.txt',
);
expect(route.url, 'https://storage.example.com/download/file.txt');
expect(route.headers, isEmpty);
expect(route.changed, isFalse);
await CustomLineService.instance.clearSelectedNode();
await StorageService.instance.clear();
});
}