修复处理内容包括公告 WebView、文件预览页、分享页兜底获取下载链接的隐藏 WebView。

This commit is contained in:
2026-05-27 23:47:10 +08:00
parent 12f2c2660e
commit 24d20cbfcb
4 changed files with 545 additions and 242 deletions
@@ -1,11 +1,17 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart' as desktop;
import 'package:webview_flutter/webview_flutter.dart' as mobile;
import '../../../data/models/file_model.dart'; import '../../../data/models/file_model.dart';
import '../../../services/api_service.dart'; import '../../../services/api_service.dart';
bool get _useDesktopWebView =>
!kIsWeb && (Platform.isWindows || Platform.isLinux);
/// 完全交给 Cloudreve 官方 Web 前端处理文件打开。 /// 完全交给 Cloudreve 官方 Web 前端处理文件打开。
/// ///
/// 这里不再自己读取 file_viewers,也不再自己创建 viewerSession。 /// 这里不再自己读取 file_viewers,也不再自己创建 viewerSession。
@@ -26,7 +32,8 @@ class CloudreveFileAppPage extends StatefulWidget {
} }
class _CloudreveFileAppPageState extends State<CloudreveFileAppPage> { class _CloudreveFileAppPageState extends State<CloudreveFileAppPage> {
late final WebViewController _controller; mobile.WebViewController? _mobileController;
desktop.InAppWebViewController? _desktopController;
bool _isPreparing = true; bool _isPreparing = true;
bool _sessionInjected = false; bool _sessionInjected = false;
@@ -40,11 +47,12 @@ class _CloudreveFileAppPageState extends State<CloudreveFileAppPage> {
void initState() { void initState() {
super.initState(); super.initState();
_controller = WebViewController() if (!_useDesktopWebView) {
..setJavaScriptMode(JavaScriptMode.unrestricted) _mobileController = mobile.WebViewController()
..setJavaScriptMode(mobile.JavaScriptMode.unrestricted)
..setBackgroundColor(Colors.transparent) ..setBackgroundColor(Colors.transparent)
..setNavigationDelegate( ..setNavigationDelegate(
NavigationDelegate( mobile.NavigationDelegate(
onProgress: (progress) { onProgress: (progress) {
if (!mounted) return; if (!mounted) return;
setState(() => _progress = progress); setState(() => _progress = progress);
@@ -60,16 +68,26 @@ class _CloudreveFileAppPageState extends State<CloudreveFileAppPage> {
}, },
), ),
); );
}
_prepareAndOpen(); _prepareAndOpen();
} }
@override
void dispose() {
_desktopController?.dispose();
super.dispose();
}
Future<void> _prepareAndOpen() async { Future<void> _prepareAndOpen() async {
setState(() { setState(() {
_isPreparing = true; _isPreparing = true;
_error = null; _error = null;
_progress = 0; _progress = 0;
_sessionInjected = false; _sessionInjected = false;
if (_useDesktopWebView) {
_desktopController = null;
}
}); });
try { try {
@@ -80,7 +98,7 @@ class _CloudreveFileAppPageState extends State<CloudreveFileAppPage> {
// 先打开同源页面,再注入 localStorage。 // 先打开同源页面,再注入 localStorage。
// 这样 Cloudreve 官方前端可以直接复用 App 的 access_token。 // 这样 Cloudreve 官方前端可以直接复用 App 的 access_token。
await _controller.loadRequest(_originUri); await _loadOrigin();
if (!mounted) return; if (!mounted) return;
setState(() => _isPreparing = false); setState(() => _isPreparing = false);
@@ -105,7 +123,9 @@ class _CloudreveFileAppPageState extends State<CloudreveFileAppPage> {
Uri _buildCloudreveHomeUri() { Uri _buildCloudreveHomeUri() {
final parent = _parentUri(widget.file.relativePath); final parent = _parentUri(widget.file.relativePath);
final openTarget = widget.file.id.isNotEmpty ? widget.file.id : widget.file.relativePath; final openTarget = widget.file.id.isNotEmpty
? widget.file.id
: widget.file.relativePath;
return Uri( return Uri(
scheme: _originUri.scheme, scheme: _originUri.scheme,
@@ -121,7 +141,9 @@ class _CloudreveFileAppPageState extends State<CloudreveFileAppPage> {
} }
String _parentUri(String uri) { String _parentUri(String uri) {
final normalized = uri.endsWith('/') ? uri.substring(0, uri.length - 1) : uri; final normalized = uri.endsWith('/')
? uri.substring(0, uri.length - 1)
: uri;
final index = normalized.lastIndexOf('/'); final index = normalized.lastIndexOf('/');
if (index <= 'cloudreve://my'.length) { if (index <= 'cloudreve://my'.length) {
return 'cloudreve://my'; return 'cloudreve://my';
@@ -138,7 +160,9 @@ class _CloudreveFileAppPageState extends State<CloudreveFileAppPage> {
Map<String, dynamic>? user; Map<String, dynamic>? user;
try { try {
final response = await ApiService.instance.get<Map<String, dynamic>>('/user/me'); final response = await ApiService.instance.get<Map<String, dynamic>>(
'/user/me',
);
user = Map<String, dynamic>.from(response); user = Map<String, dynamic>.from(response);
} catch (_) { } catch (_) {
user = null; user = null;
@@ -181,15 +205,16 @@ class _CloudreveFileAppPageState extends State<CloudreveFileAppPage> {
try { try {
if (sessionJson != null) { if (sessionJson != null) {
final script = ''' final script =
'''
try { try {
localStorage.setItem('cloudreve_session', ${jsonEncode(sessionJson)}); localStorage.setItem('cloudreve_session', ${jsonEncode(sessionJson)});
} catch (e) {} } catch (e) {}
window.location.replace(${jsonEncode(target)}); window.location.replace(${jsonEncode(target)});
'''; ''';
await _controller.runJavaScript(script); await _runJavaScript(script);
} else { } else {
await _controller.loadRequest(_targetUri); await _loadTarget();
} }
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
@@ -199,17 +224,52 @@ class _CloudreveFileAppPageState extends State<CloudreveFileAppPage> {
Future<void> _openTargetAgain() async { Future<void> _openTargetAgain() async {
_sessionInjected = true; _sessionInjected = true;
await _controller.loadRequest(_targetUri); await _loadTarget();
}
Future<void> _reloadWebView() async {
if (_useDesktopWebView) {
await _desktopController?.reload();
} else {
await _mobileController?.reload();
}
}
Future<void> _loadOrigin() async {
if (_useDesktopWebView) {
final controller = _desktopController;
if (controller == null) return;
await _desktopController?.loadUrl(
urlRequest: desktop.URLRequest(url: desktop.WebUri.uri(_originUri)),
);
} else {
await _mobileController!.loadRequest(_originUri);
}
}
Future<void> _loadTarget() async {
if (_useDesktopWebView) {
await _desktopController?.loadUrl(
urlRequest: desktop.URLRequest(url: desktop.WebUri.uri(_targetUri)),
);
} else {
await _mobileController!.loadRequest(_targetUri);
}
}
Future<void> _runJavaScript(String source) async {
if (_useDesktopWebView) {
await _desktopController?.evaluateJavascript(source: source);
} else {
await _mobileController!.runJavaScript(source);
}
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text( title: Text(widget.file.name, overflow: TextOverflow.ellipsis),
widget.file.name,
overflow: TextOverflow.ellipsis,
),
actions: [ actions: [
IconButton( IconButton(
tooltip: '重新打开', tooltip: '重新打开',
@@ -219,7 +279,7 @@ class _CloudreveFileAppPageState extends State<CloudreveFileAppPage> {
IconButton( IconButton(
tooltip: '刷新', tooltip: '刷新',
icon: const Icon(Icons.refresh), icon: const Icon(Icons.refresh),
onPressed: () => _controller.reload(), onPressed: _reloadWebView,
), ),
], ],
bottom: PreferredSize( bottom: PreferredSize(
@@ -260,6 +320,32 @@ class _CloudreveFileAppPageState extends State<CloudreveFileAppPage> {
); );
} }
return WebViewWidget(controller: _controller); if (_useDesktopWebView) {
return desktop.InAppWebView(
initialSettings: desktop.InAppWebViewSettings(
javaScriptEnabled: true,
domStorageEnabled: true,
transparentBackground: true,
supportZoom: false,
),
onWebViewCreated: (controller) async {
_desktopController = controller;
if (!_isPreparing) {
await _loadOrigin();
}
},
onProgressChanged: (_, progress) {
if (!mounted) return;
setState(() => _progress = progress);
},
onLoadStop: (_, url) => _injectSessionAndOpenIfNeeded(),
onReceivedError: (_, request, error) {
if (!mounted || request.isForMainFrame != true) return;
setState(() => _error = '${error.type}: ${error.description}');
},
);
}
return mobile.WebViewWidget(controller: _mobileController!);
} }
} }
+254 -110
View File
@@ -1,11 +1,14 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart' as desktop;
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons/lucide_icons.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:webview_flutter/webview_flutter.dart'; import 'package:webview_flutter/webview_flutter.dart' as mobile;
import '../../../core/utils/app_logger.dart'; import '../../../core/utils/app_logger.dart';
import '../../../services/api_service.dart'; import '../../../services/api_service.dart';
@@ -14,13 +17,13 @@ import '../../providers/download_manager_provider.dart';
import '../../widgets/folder_picker.dart'; import '../../widgets/folder_picker.dart';
import '../../widgets/user_avatar.dart'; import '../../widgets/user_avatar.dart';
bool get _useDesktopWebView =>
!kIsWeb && (Platform.isWindows || Platform.isLinux);
class ShareLinkPage extends StatefulWidget { class ShareLinkPage extends StatefulWidget {
final ShareLinkCandidate candidate; final ShareLinkCandidate candidate;
const ShareLinkPage({ const ShareLinkPage({super.key, required this.candidate});
super.key,
required this.candidate,
});
@override @override
State<ShareLinkPage> createState() => _ShareLinkPageState(); State<ShareLinkPage> createState() => _ShareLinkPageState();
@@ -49,7 +52,9 @@ class _ShareLinkPageState extends State<ShareLinkPage> {
final baseUri = Uri.tryParse(ApiService.instance.dio.options.baseUrl); final baseUri = Uri.tryParse(ApiService.instance.dio.options.baseUrl);
if (shareUri == null || baseUri == null) return false; if (shareUri == null || baseUri == null) return false;
final same = shareUri.host == baseUri.host; final same = shareUri.host == baseUri.host;
AppLogger.d('ShareLinkPage _isSameOrigin: shareHost=${shareUri.host}, baseHost=${baseUri.host}, result=$same'); AppLogger.d(
'ShareLinkPage _isSameOrigin: shareHost=${shareUri.host}, baseHost=${baseUri.host}, result=$same',
);
return same; return same;
} }
@@ -165,8 +170,11 @@ class _ShareLinkPageState extends State<ShareLinkPage> {
final info = _info; final info = _info;
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_singleFile = info == null ? null : ShareLinkService.instance.fileFromShareInfo(info); _singleFile = info == null
_contextHint = _singleFile?.contextHint ?? _contextHint ?? info?.contextHint; ? null
: ShareLinkService.instance.fileFromShareInfo(info);
_contextHint =
_singleFile?.contextHint ?? _contextHint ?? info?.contextHint;
_fileError = null; _fileError = null;
_loadingFiles = false; _loadingFiles = false;
}); });
@@ -280,9 +288,9 @@ class _ShareLinkPageState extends State<ShareLinkPage> {
return; return;
} catch (downloadError) { } catch (downloadError) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text('创建下载任务失败:$downloadError')), context,
); ).showSnackBar(SnackBar(content: Text('创建下载任务失败:$downloadError')));
return; return;
} }
} }
@@ -318,13 +326,10 @@ class _ShareLinkPageState extends State<ShareLinkPage> {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(content: Text(task == null ? '该文件已在下载队列中' : '已添加到下载队列,正在下载')),
content: Text(task == null ? '该文件已在下载队列中' : '已添加到下载队列,正在下载'),
),
); );
} }
Future<String?> _obtainDownloadUrlFromOfficialPage({ Future<String?> _obtainDownloadUrlFromOfficialPage({
required String fileName, required String fileName,
}) async { }) async {
@@ -332,7 +337,7 @@ class _ShareLinkPageState extends State<ShareLinkPage> {
if (shareUri == null) return null; if (shareUri == null) return null;
final completer = Completer<String?>(); final completer = Completer<String?>();
late final WebViewController controller; mobile.WebViewController? mobileController;
BuildContext? dialogContext; BuildContext? dialogContext;
Timer? timeoutTimer; Timer? timeoutTimer;
@@ -341,8 +346,9 @@ class _ShareLinkPageState extends State<ShareLinkPage> {
completer.complete(url); completer.complete(url);
} }
controller = WebViewController() if (!_useDesktopWebView) {
..setJavaScriptMode(JavaScriptMode.unrestricted) mobileController = mobile.WebViewController()
..setJavaScriptMode(mobile.JavaScriptMode.unrestricted)
..addJavaScriptChannel( ..addJavaScriptChannel(
'CloudreveDownloadBridge', 'CloudreveDownloadBridge',
onMessageReceived: (message) { onMessageReceived: (message) {
@@ -356,27 +362,29 @@ class _ShareLinkPageState extends State<ShareLinkPage> {
}, },
) )
..setNavigationDelegate( ..setNavigationDelegate(
NavigationDelegate( mobile.NavigationDelegate(
onPageFinished: (_) async { onPageFinished: (_) async {
await _installOfficialDownloadHook(controller); await _installOfficialDownloadHookForMobile(mobileController!);
}, },
onNavigationRequest: (request) { onNavigationRequest: (request) {
final url = request.url; final url = request.url;
if (_looksLikeDirectDownloadUrl(url, shareUri: shareUri)) { if (_looksLikeDirectDownloadUrl(url, shareUri: shareUri)) {
complete(url); complete(url);
return NavigationDecision.prevent; return mobile.NavigationDecision.prevent;
} }
return NavigationDecision.navigate; return mobile.NavigationDecision.navigate;
}, },
onUrlChange: (change) { onUrlChange: (change) {
final url = change.url; final url = change.url;
if (url != null && _looksLikeDirectDownloadUrl(url, shareUri: shareUri)) { if (url != null &&
_looksLikeDirectDownloadUrl(url, shareUri: shareUri)) {
complete(url); complete(url);
} }
}, },
), ),
) )
..loadRequest(shareUri); ..loadRequest(shareUri);
}
timeoutTimer = Timer(const Duration(seconds: 24), () => complete(null)); timeoutTimer = Timer(const Duration(seconds: 24), () => complete(null));
@@ -409,7 +417,9 @@ class _ShareLinkPageState extends State<ShareLinkPage> {
const SizedBox(height: 10), const SizedBox(height: 10),
Text( Text(
'不会自动跳转浏览器;浏览器入口只作为手动备用方案。', '不会自动跳转浏览器;浏览器入口只作为手动备用方案。',
style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor), style: theme.textTheme.bodySmall?.copyWith(
color: theme.hintColor,
),
), ),
const SizedBox(height: 1), const SizedBox(height: 1),
SizedBox( SizedBox(
@@ -417,7 +427,32 @@ class _ShareLinkPageState extends State<ShareLinkPage> {
height: 1, height: 1,
child: Opacity( child: Opacity(
opacity: 0.01, opacity: 0.01,
child: WebViewWidget(controller: controller), child: _useDesktopWebView
? _OfficialDownloadDesktopWebView(
shareUri: shareUri,
onBridgeMessage: (message) {
final url = _extractOfficialDownloadUrl(
message,
shareUri: shareUri,
);
if (url != null && url.isNotEmpty) {
complete(url);
}
},
onNavigationUrl: (url) {
if (_looksLikeDirectDownloadUrl(
url,
shareUri: shareUri,
)) {
complete(url);
return true;
}
return false;
},
buildHookScript: _officialDownloadHookScript,
buildClickScript: _officialDownloadClickScript,
)
: mobile.WebViewWidget(controller: mobileController!),
), ),
), ),
], ],
@@ -427,10 +462,7 @@ class _ShareLinkPageState extends State<ShareLinkPage> {
onPressed: () => complete(null), onPressed: () => complete(null),
child: const Text('取消'), child: const Text('取消'),
), ),
TextButton( TextButton(onPressed: _openExternal, child: const Text('浏览器打开')),
onPressed: _openExternal,
child: const Text('浏览器打开'),
),
], ],
); );
}, },
@@ -440,8 +472,18 @@ class _ShareLinkPageState extends State<ShareLinkPage> {
return result; return result;
} }
Future<void> _installOfficialDownloadHook(WebViewController controller) async { Future<void> _installOfficialDownloadHookForMobile(
const hookScript = r""" mobile.WebViewController controller,
) async {
try {
await controller.runJavaScript(_officialDownloadHookScript);
await controller.runJavaScript(_officialDownloadClickScript);
} catch (_) {
// The outer flow times out and leaves the browser fallback available.
}
}
static const String _officialDownloadHookScript = r"""
(function () { (function () {
if (window.__cloudreveAppDownloadHookInstalled) return; if (window.__cloudreveAppDownloadHookInstalled) return;
window.__cloudreveAppDownloadHookInstalled = true; window.__cloudreveAppDownloadHookInstalled = true;
@@ -449,7 +491,11 @@ class _ShareLinkPageState extends State<ShareLinkPage> {
function post(value) { function post(value) {
try { try {
if (typeof value !== 'string') value = JSON.stringify(value); if (typeof value !== 'string') value = JSON.stringify(value);
if (typeof CloudreveDownloadBridge !== 'undefined' && CloudreveDownloadBridge.postMessage) {
CloudreveDownloadBridge.postMessage(value); CloudreveDownloadBridge.postMessage(value);
} else if (window.flutter_inappwebview && window.flutter_inappwebview.callHandler) {
window.flutter_inappwebview.callHandler('CloudreveDownloadBridge', value);
}
} catch (e) {} } catch (e) {}
} }
@@ -521,7 +567,7 @@ class _ShareLinkPageState extends State<ShareLinkPage> {
})(); })();
"""; """;
const clickScript = r""" static const String _officialDownloadClickScript = r"""
(function () { (function () {
function visible(el) { function visible(el) {
if (!el) return false; if (!el) return false;
@@ -571,20 +617,13 @@ class _ShareLinkPageState extends State<ShareLinkPage> {
})(); })();
"""; """;
try {
await controller.runJavaScript(hookScript);
await controller.runJavaScript(clickScript);
} catch (_) {
// 官方页面脚本注入失败时,外层会超时并保留“浏览器打开”。
}
}
String? _extractOfficialDownloadUrl(String message, {required Uri shareUri}) { String? _extractOfficialDownloadUrl(String message, {required Uri shareUri}) {
final text = message.trim(); final text = message.trim();
if (text.isEmpty) return null; if (text.isEmpty) return null;
final direct = _normalizeOfficialUrl(text, shareUri: shareUri); final direct = _normalizeOfficialUrl(text, shareUri: shareUri);
if (direct != null && _looksLikeDirectDownloadUrl(direct, shareUri: shareUri)) { if (direct != null &&
_looksLikeDirectDownloadUrl(direct, shareUri: shareUri)) {
return direct; return direct;
} }
@@ -601,7 +640,8 @@ class _ShareLinkPageState extends State<ShareLinkPage> {
if (value is String) { if (value is String) {
final normalized = _normalizeOfficialUrl(value, shareUri: shareUri); final normalized = _normalizeOfficialUrl(value, shareUri: shareUri);
if (normalized != null && _looksLikeDirectDownloadUrl(normalized, shareUri: shareUri)) { if (normalized != null &&
_looksLikeDirectDownloadUrl(normalized, shareUri: shareUri)) {
return normalized; return normalized;
} }
return null; return null;
@@ -618,8 +658,12 @@ class _ShareLinkPageState extends State<ShareLinkPage> {
if (value is Map) { if (value is Map) {
final navigationUrl = value['__navigation_url']; final navigationUrl = value['__navigation_url'];
if (navigationUrl is String) { if (navigationUrl is String) {
final normalized = _normalizeOfficialUrl(navigationUrl, shareUri: shareUri); final normalized = _normalizeOfficialUrl(
if (normalized != null && _looksLikeDirectDownloadUrl(normalized, shareUri: shareUri)) { navigationUrl,
shareUri: shareUri,
);
if (normalized != null &&
_looksLikeDirectDownloadUrl(normalized, shareUri: shareUri)) {
return normalized; return normalized;
} }
} }
@@ -678,7 +722,9 @@ class _ShareLinkPageState extends State<ShareLinkPage> {
final path = uri.path.toLowerCase(); final path = uri.path.toLowerCase();
if (sameHost) { if (sameHost) {
if (path.startsWith('/s/') || path == '/home' || path.startsWith('/home/')) { if (path.startsWith('/s/') ||
path == '/home' ||
path.startsWith('/home/')) {
return false; return false;
} }
if (path.contains('/api/v4/file/url')) return false; if (path.contains('/api/v4/file/url')) return false;
@@ -706,10 +752,8 @@ class _ShareLinkPageState extends State<ShareLinkPage> {
setState(() => _saving = true); setState(() => _saving = true);
try { try {
final isRootShareFolder = ShareLinkService.isShareRootUri( final isRootShareFolder =
uri, ShareLinkService.isShareRootUri(uri, shareId: widget.candidate.id) &&
shareId: widget.candidate.id,
) &&
_files.isNotEmpty; _files.isNotEmpty;
final urisToSave = isRootShareFolder final urisToSave = isRootShareFolder
? _files ? _files
@@ -728,14 +772,14 @@ class _ShareLinkPageState extends State<ShareLinkPage> {
); );
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text('已转存「$name」到 $destination')), context,
); ).showSnackBar(SnackBar(content: Text('已转存「$name」到 $destination')));
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text('转存失败:$e')), context,
); ).showSnackBar(SnackBar(content: Text('转存失败:$e')));
} finally { } finally {
if (mounted) setState(() => _saving = false); if (mounted) setState(() => _saving = false);
} }
@@ -760,9 +804,9 @@ class _ShareLinkPageState extends State<ShareLinkPage> {
children: [ children: [
Text( Text(
'选择转存位置', '选择转存位置',
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: Theme.of(
fontWeight: FontWeight.w800, context,
), ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w800),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
FolderPicker( FolderPicker(
@@ -880,9 +924,8 @@ class _ShareLinkPageState extends State<ShareLinkPage> {
isDense: true, isDense: true,
border: OutlineInputBorder(), border: OutlineInputBorder(),
), ),
onSubmitted: (_) => _loadShare( onSubmitted: (_) =>
password: _passwordController.text.trim(), _loadShare(password: _passwordController.text.trim()),
),
), ),
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
@@ -910,13 +953,18 @@ class _ShareLinkPageState extends State<ShareLinkPage> {
CircleAvatar( CircleAvatar(
radius: 28, radius: 28,
backgroundColor: theme.colorScheme.primaryContainer, backgroundColor: theme.colorScheme.primaryContainer,
child: Icon(Icons.ios_share, color: theme.colorScheme.onPrimaryContainer), child: Icon(
Icons.ios_share,
color: theme.colorScheme.onPrimaryContainer,
),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: Text( child: Text(
'分享链接', '分享链接',
style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w800), style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800,
),
), ),
), ),
], ],
@@ -933,11 +981,7 @@ class _ShareLinkPageState extends State<ShareLinkPage> {
return Row( return Row(
children: [ children: [
_isSameOrigin _isSameOrigin
? UserAvatar( ? UserAvatar(userId: ownerId, displayName: ownerName, radius: 29)
userId: ownerId,
displayName: ownerName,
radius: 29,
)
: CircleAvatar( : CircleAvatar(
radius: 29, radius: 29,
backgroundColor: theme.colorScheme.primaryContainer, backgroundColor: theme.colorScheme.primaryContainer,
@@ -966,7 +1010,9 @@ class _ShareLinkPageState extends State<ShareLinkPage> {
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
'向您分享了 ${info.isFolder ? '一个文件夹' : '一个文件'}', '向您分享了 ${info.isFolder ? '一个文件夹' : '一个文件'}',
style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor), style: theme.textTheme.bodySmall?.copyWith(
color: theme.hintColor,
),
), ),
], ],
), ),
@@ -974,7 +1020,9 @@ class _ShareLinkPageState extends State<ShareLinkPage> {
const SizedBox(width: 8), const SizedBox(width: 8),
_StatusBadge( _StatusBadge(
text: info.expired ? '已过期' : '有效', text: info.expired ? '已过期' : '有效',
color: info.expired ? theme.colorScheme.error : theme.colorScheme.primary, color: info.expired
? theme.colorScheme.error
: theme.colorScheme.primary,
), ),
], ],
); );
@@ -1009,7 +1057,9 @@ class _ShareLinkPageState extends State<ShareLinkPage> {
info.name, info.name,
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w900), style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w900,
),
), ),
), ),
], ],
@@ -1020,12 +1070,20 @@ class _ShareLinkPageState extends State<ShareLinkPage> {
runSpacing: 8, runSpacing: 8,
children: [ children: [
_MetaChip(icon: LucideIcons.eye, text: '${info.visited} 次访问'), _MetaChip(icon: LucideIcons.eye, text: '${info.visited} 次访问'),
if (sizeText != null) _MetaChip(icon: Icons.sd_storage_outlined, text: sizeText), if (sizeText != null)
_MetaChip(icon: Icons.sd_storage_outlined, text: sizeText),
if (info.createdAt != null) if (info.createdAt != null)
_MetaChip(icon: LucideIcons.calendar, text: '${_formatDate(info.createdAt!)} 创建'), _MetaChip(
icon: LucideIcons.calendar,
text: '${_formatDate(info.createdAt!)} 创建',
),
if (info.expires != null) if (info.expires != null)
_MetaChip(icon: LucideIcons.clock, text: '${_formatDate(info.expires!)} 过期'), _MetaChip(
if (info.isPrivate) const _MetaChip(icon: LucideIcons.lock, text: '私密分享'), icon: LucideIcons.clock,
text: '${_formatDate(info.expires!)} 过期',
),
if (info.isPrivate)
const _MetaChip(icon: LucideIcons.lock, text: '私密分享'),
], ],
), ),
], ],
@@ -1082,7 +1140,8 @@ class _ShareLinkPageState extends State<ShareLinkPage> {
} }
Widget _buildSingleFileCard(BuildContext context, ShareLinkInfo info) { Widget _buildSingleFileCard(BuildContext context, ShareLinkInfo info) {
final file = _singleFile ?? ShareLinkService.instance.fileFromShareInfo(info); final file =
_singleFile ?? ShareLinkService.instance.fileFromShareInfo(info);
final sourceUri = (info.sourceUri?.trim().isNotEmpty == true) final sourceUri = (info.sourceUri?.trim().isNotEmpty == true)
? info.sourceUri ? info.sourceUri
: (file.path.trim().isNotEmpty ? file.path : null); : (file.path.trim().isNotEmpty ? file.path : null);
@@ -1108,17 +1167,15 @@ class _ShareLinkPageState extends State<ShareLinkPage> {
info.name, info.name,
maxLines: 3, maxLines: 3,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: Theme.of(context).textTheme.titleMedium
fontWeight: FontWeight.w900, ?.copyWith(fontWeight: FontWeight.w900),
),
), ),
...[ ...[
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
'${file.size > 0 ? _ShareFileTile.formatSize(file.size) : '分享文件'}${file.updatedAt == null ? '' : ' · ${_formatDate(file.updatedAt!)}'}', '${file.size > 0 ? _ShareFileTile.formatSize(file.size) : '分享文件'}${file.updatedAt == null ? '' : ' · ${_formatDate(file.updatedAt!)}'}',
style: Theme.of(context).textTheme.bodySmall?.copyWith( style: Theme.of(context).textTheme.bodySmall
color: Theme.of(context).hintColor, ?.copyWith(color: Theme.of(context).hintColor),
),
), ),
], ],
], ],
@@ -1135,7 +1192,9 @@ class _ShareLinkPageState extends State<ShareLinkPage> {
_ErrorBox( _ErrorBox(
text: '文件详情读取失败,仍可尝试下载或转存:$_fileError', text: '文件详情读取失败,仍可尝试下载或转存:$_fileError',
actionText: sourceUri == null ? null : '重试', actionText: sourceUri == null ? null : '重试',
onAction: sourceUri == null ? null : () => _loadSingleFile(sourceUri), onAction: sourceUri == null
? null
: () => _loadSingleFile(sourceUri),
), ),
], ],
const SizedBox(height: 18), const SizedBox(height: 18),
@@ -1248,7 +1307,8 @@ class _ShareLinkPageState extends State<ShareLinkPage> {
subtitle: '这个分享目录下没有文件。', subtitle: '这个分享目录下没有文件。',
) )
else else
..._files.map((file) => _ShareFileTile( ..._files.map(
(file) => _ShareFileTile(
file: file, file: file,
onTap: file.isFolder ? () => _enterFolder(file) : null, onTap: file.isFolder ? () => _enterFolder(file) : null,
onDownload: file.isFile onDownload: file.isFile
@@ -1259,8 +1319,11 @@ class _ShareLinkPageState extends State<ShareLinkPage> {
entity: file.primaryEntity, entity: file.primaryEntity,
) )
: null, : null,
onSave: _isSameOrigin ? () => _saveSharedUri(file.path, name: file.name) : null, onSave: _isSameOrigin
)), ? () => _saveSharedUri(file.path, name: file.name)
: null,
),
),
], ],
); );
} }
@@ -1271,7 +1334,9 @@ class _ShareLinkPageState extends State<ShareLinkPage> {
margin: const EdgeInsets.only(bottom: 12), margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.55), color: theme.colorScheme.surfaceContainerHighest.withValues(
alpha: 0.55,
),
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
), ),
child: SingleChildScrollView( child: SingleChildScrollView(
@@ -1287,12 +1352,17 @@ class _ShareLinkPageState extends State<ShareLinkPage> {
onTap: isLast ? null : () => _jumpToBreadcrumb(index), onTap: isLast ? null : () => _jumpToBreadcrumb(index),
borderRadius: BorderRadius.circular(999), borderRadius: BorderRadius.circular(999),
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 5,
),
child: Text( child: Text(
item.title, item.title,
style: TextStyle( style: TextStyle(
fontWeight: isLast ? FontWeight.w800 : FontWeight.w500, fontWeight: isLast ? FontWeight.w800 : FontWeight.w500,
color: isLast ? theme.colorScheme.primary : theme.hintColor, color: isLast
? theme.colorScheme.primary
: theme.hintColor,
), ),
), ),
), ),
@@ -1320,6 +1390,93 @@ class _ShareBreadcrumb {
const _ShareBreadcrumb({required this.title, required this.uri}); const _ShareBreadcrumb({required this.title, required this.uri});
} }
class _OfficialDownloadDesktopWebView extends StatefulWidget {
final Uri shareUri;
final ValueChanged<String> onBridgeMessage;
final bool Function(String url) onNavigationUrl;
final String buildHookScript;
final String buildClickScript;
const _OfficialDownloadDesktopWebView({
required this.shareUri,
required this.onBridgeMessage,
required this.onNavigationUrl,
required this.buildHookScript,
required this.buildClickScript,
});
@override
State<_OfficialDownloadDesktopWebView> createState() =>
_OfficialDownloadDesktopWebViewState();
}
class _OfficialDownloadDesktopWebViewState
extends State<_OfficialDownloadDesktopWebView> {
desktop.InAppWebViewController? _controller;
@override
void dispose() {
_controller?.dispose();
super.dispose();
}
Future<void> _installHook() async {
final controller = _controller;
if (controller == null) return;
try {
await controller.evaluateJavascript(source: widget.buildHookScript);
await controller.evaluateJavascript(source: widget.buildClickScript);
} catch (_) {
// The parent dialog timeout keeps the manual browser fallback available.
}
}
@override
Widget build(BuildContext context) {
return desktop.InAppWebView(
initialUrlRequest: desktop.URLRequest(
url: desktop.WebUri.uri(widget.shareUri),
),
initialSettings: desktop.InAppWebViewSettings(
javaScriptEnabled: true,
domStorageEnabled: true,
supportZoom: false,
transparentBackground: true,
useShouldOverrideUrlLoading: true,
useOnDownloadStart: true,
),
onWebViewCreated: (controller) {
_controller = controller;
controller.addJavaScriptHandler(
handlerName: 'CloudreveDownloadBridge',
callback: (args) {
if (args.isNotEmpty) {
widget.onBridgeMessage(args.first.toString());
}
},
);
},
onLoadStop: (_, url) => _installHook(),
shouldOverrideUrlLoading: (_, action) {
final url = action.request.url?.toString();
if (url != null && widget.onNavigationUrl(url)) {
return desktop.NavigationActionPolicy.CANCEL;
}
return desktop.NavigationActionPolicy.ALLOW;
},
onUpdateVisitedHistory: (_, url, isReload) {
if (url != null) {
widget.onNavigationUrl(url.toString());
}
},
onDownloadStarting: (_, request) {
widget.onNavigationUrl(request.url.toString());
return null;
},
);
}
}
class _ShareFileTile extends StatelessWidget { class _ShareFileTile extends StatelessWidget {
final ShareLinkFile file; final ShareLinkFile file;
final VoidCallback? onTap; final VoidCallback? onTap;
@@ -1426,7 +1583,9 @@ class _ShareFileTile extends StatelessWidget {
size /= 1024; size /= 1024;
unitIndex++; unitIndex++;
} }
final text = unitIndex == 0 ? size.toStringAsFixed(0) : size.toStringAsFixed(1); final text = unitIndex == 0
? size.toStringAsFixed(0)
: size.toStringAsFixed(1);
return '$text ${units[unitIndex]}'; return '$text ${units[unitIndex]}';
} }
@@ -1440,10 +1599,7 @@ class _MetaChip extends StatelessWidget {
final IconData icon; final IconData icon;
final String text; final String text;
const _MetaChip({ const _MetaChip({required this.icon, required this.text});
required this.icon,
required this.text,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -1498,11 +1654,7 @@ class _ErrorBox extends StatelessWidget {
final String? actionText; final String? actionText;
final VoidCallback? onAction; final VoidCallback? onAction;
const _ErrorBox({ const _ErrorBox({required this.text, this.actionText, this.onAction});
required this.text,
this.actionText,
this.onAction,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -1525,10 +1677,7 @@ class _ErrorBox extends StatelessWidget {
const SizedBox(height: 8), const SizedBox(height: 8),
Align( Align(
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
child: TextButton( child: TextButton(onPressed: onAction, child: Text(actionText!)),
onPressed: onAction,
child: Text(actionText!),
),
), ),
], ],
], ],
@@ -1573,16 +1722,11 @@ class _EmptyState extends StatelessWidget {
Text( Text(
subtitle, subtitle,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor),
color: theme.hintColor,
),
), ),
if (actionText != null && onAction != null) ...[ if (actionText != null && onAction != null) ...[
const SizedBox(height: 14), const SizedBox(height: 14),
OutlinedButton( OutlinedButton(onPressed: onAction, child: Text(actionText!)),
onPressed: onAction,
child: Text(actionText!),
),
], ],
], ],
), ),
@@ -1,7 +1,13 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart' as desktop;
import 'package:webview_flutter/webview_flutter.dart' as mobile;
bool get _useDesktopWebView =>
!kIsWeb && (Platform.isWindows || Platform.isLinux);
class AnnouncementDialog extends StatefulWidget { class AnnouncementDialog extends StatefulWidget {
final String title; final String title;
@@ -26,11 +32,8 @@ class AnnouncementDialog extends StatefulWidget {
await showDialog<void>( await showDialog<void>(
context: context, context: context,
barrierDismissible: true, barrierDismissible: true,
builder: (_) => AnnouncementDialog( builder: (_) =>
title: title, AnnouncementDialog(title: title, html: html, baseUrl: baseUrl),
html: html,
baseUrl: baseUrl,
),
); );
} }
@@ -39,35 +42,39 @@ class AnnouncementDialog extends StatefulWidget {
} }
class _AnnouncementDialogState extends State<AnnouncementDialog> { class _AnnouncementDialogState extends State<AnnouncementDialog> {
late final WebViewController _controller; mobile.WebViewController? _mobileController;
desktop.InAppWebViewController? _desktopController;
late final String _html;
late final String _baseUrl;
bool _loading = true; bool _loading = true;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_controller = WebViewController() final origin = Uri.parse(widget.baseUrl).origin;
..setJavaScriptMode(JavaScriptMode.unrestricted) _html = _wrapHtml(widget.html);
_baseUrl = '$origin/';
if (!_useDesktopWebView) {
_mobileController = mobile.WebViewController()
..setJavaScriptMode(mobile.JavaScriptMode.unrestricted)
..setBackgroundColor(Colors.transparent) ..setBackgroundColor(Colors.transparent)
..setNavigationDelegate( ..setNavigationDelegate(
NavigationDelegate( mobile.NavigationDelegate(
onPageFinished: (_) { onPageFinished: (_) {
if (mounted) setState(() => _loading = false); if (mounted) setState(() => _loading = false);
}, },
), ),
); )
..loadHtmlString(_html, baseUrl: _baseUrl);
_load(); }
} }
Future<void> _load() async { @override
final origin = Uri.parse(widget.baseUrl).origin; void dispose() {
final html = _wrapHtml(widget.html); _desktopController?.dispose();
super.dispose();
await _controller.loadHtmlString(
html,
baseUrl: '$origin/',
);
} }
String _wrapHtml(String body) { String _wrapHtml(String body) {
@@ -162,7 +169,7 @@ $body
Expanded( Expanded(
child: Stack( child: Stack(
children: [ children: [
WebViewWidget(controller: _controller), _buildWebView(),
if (_loading) if (_loading)
const Center(child: CircularProgressIndicator()), const Center(child: CircularProgressIndicator()),
], ],
@@ -174,4 +181,29 @@ $body
), ),
); );
} }
Widget _buildWebView() {
if (_useDesktopWebView) {
return desktop.InAppWebView(
initialData: desktop.InAppWebViewInitialData(
data: _html,
baseUrl: desktop.WebUri(_baseUrl),
),
initialSettings: desktop.InAppWebViewSettings(
javaScriptEnabled: true,
domStorageEnabled: true,
transparentBackground: true,
supportZoom: false,
),
onWebViewCreated: (controller) {
_desktopController = controller;
},
onLoadStop: (_, url) {
if (mounted) setState(() => _loading = false);
},
);
}
return mobile.WebViewWidget(controller: _mobileController!);
}
} }
+51 -10
View File
@@ -32,19 +32,54 @@ set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}")
# Use Unicode for all projects. # Use Unicode for all projects.
add_definitions(-DUNICODE -D_UNICODE) add_definitions(-DUNICODE -D_UNICODE)
# flutter_inappwebview_windows requires NuGet; help CMake find it # flutter_inappwebview_windows requires NuGet. The plugin's own CMake only
if(NOT DEFINED NUGET OR NUGET STREQUAL "NUGET-NOTFOUND") # calls find_program(NUGET nuget), so seed the cache with an absolute path.
find_program(NUGET nuget PATHS if(DEFINED NUGET AND NOT NUGET STREQUAL "" AND NOT NUGET STREQUAL "NUGET-NOTFOUND")
"$ENV{USERPROFILE}\\.local\\bin" if(NOT EXISTS "${NUGET}")
"$ENV{LOCALAPPDATA}\\Microsoft\\WinGet\\Links" unset(NUGET CACHE)
"C:/ProgramData/chocolatey/bin" unset(NUGET)
NO_DEFAULT_PATH endif()
endif()
if(NOT DEFINED NUGET OR NUGET STREQUAL "" OR NUGET STREQUAL "NUGET-NOTFOUND")
set(_NUGET_CANDIDATES
"$ENV{SystemRoot}/System32/nuget.exe"
"$ENV{windir}/System32/nuget.exe"
"C:/Windows/System32/nuget.exe"
"$ENV{SystemRoot}/Sysnative/nuget.exe"
"$ENV{windir}/Sysnative/nuget.exe"
"C:/Windows/Sysnative/nuget.exe"
"$ENV{USERPROFILE}/.local/bin/nuget.exe"
"$ENV{LOCALAPPDATA}/Microsoft/WinGet/Links/nuget.exe"
"C:/ProgramData/chocolatey/bin/nuget.exe"
) )
find_program(NUGET nuget) foreach(_NUGET_CANDIDATE IN LISTS _NUGET_CANDIDATES)
if(NUGET AND NOT NUGET STREQUAL "NUGET-NOTFOUND") if(EXISTS "${_NUGET_CANDIDATE}")
set(NUGET "${_NUGET_CANDIDATE}")
break()
endif()
endforeach()
endif()
if(NOT DEFINED NUGET OR NUGET STREQUAL "" OR NUGET STREQUAL "NUGET-NOTFOUND")
find_program(NUGET NAMES nuget.exe nuget)
endif()
if(NUGET AND NOT NUGET STREQUAL "NUGET-NOTFOUND")
set(_PROJECT_NUGET "${CMAKE_BINARY_DIR}/nuget.exe")
execute_process(
COMMAND "${CMAKE_COMMAND}" -E copy_if_different "${NUGET}" "${_PROJECT_NUGET}"
RESULT_VARIABLE _NUGET_COPY_RESULT
)
if(_NUGET_COPY_RESULT EQUAL 0 AND EXISTS "${_PROJECT_NUGET}")
set(NUGET "${_PROJECT_NUGET}")
else()
message(WARNING "Failed to copy NuGet to ${_PROJECT_NUGET}; using ${NUGET}")
endif()
message(STATUS "Found NuGet: ${NUGET}") message(STATUS "Found NuGet: ${NUGET}")
set(NUGET "${NUGET}" CACHE FILEPATH "nuget executable" FORCE) set(NUGET "${NUGET}" CACHE FILEPATH "nuget executable" FORCE)
endif() else()
message(WARNING "NuGet executable was not found. Windows WebView builds may fail.")
endif() endif()
# Compilation settings that should be applied to most targets. # Compilation settings that should be applied to most targets.
@@ -125,6 +160,12 @@ ENSURE_WINDOWS_ARCHIVE(
# Generated plugin build rules, which manage building the plugins and adding # Generated plugin build rules, which manage building the plugins and adding
# them to the application. # them to the application.
if(POLICY CMP0175)
# flutter_inappwebview_windows 0.7.0-beta.3 passes DEPENDS to the TARGET
# form of add_custom_command. Keep older CMake behavior for plugins that do
# not set this policy themselves.
set(CMAKE_POLICY_DEFAULT_CMP0175 OLD)
endif()
include(flutter/generated_plugins.cmake) include(flutter/generated_plugins.cmake)
if(MSVC) if(MSVC)