diff --git a/lib/presentation/pages/preview/cloudreve_file_app_page.dart b/lib/presentation/pages/preview/cloudreve_file_app_page.dart index 2a35a45..10275a0 100644 --- a/lib/presentation/pages/preview/cloudreve_file_app_page.dart +++ b/lib/presentation/pages/preview/cloudreve_file_app_page.dart @@ -1,11 +1,17 @@ import 'dart:convert'; +import 'dart:io'; +import 'package:flutter/foundation.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 '../../../services/api_service.dart'; +bool get _useDesktopWebView => + !kIsWeb && (Platform.isWindows || Platform.isLinux); + /// 完全交给 Cloudreve 官方 Web 前端处理文件打开。 /// /// 这里不再自己读取 file_viewers,也不再自己创建 viewerSession。 @@ -26,7 +32,8 @@ class CloudreveFileAppPage extends StatefulWidget { } class _CloudreveFileAppPageState extends State { - late final WebViewController _controller; + mobile.WebViewController? _mobileController; + desktop.InAppWebViewController? _desktopController; bool _isPreparing = true; bool _sessionInjected = false; @@ -40,36 +47,47 @@ class _CloudreveFileAppPageState extends State { void initState() { super.initState(); - _controller = WebViewController() - ..setJavaScriptMode(JavaScriptMode.unrestricted) - ..setBackgroundColor(Colors.transparent) - ..setNavigationDelegate( - NavigationDelegate( - onProgress: (progress) { - if (!mounted) return; - setState(() => _progress = progress); - }, - onPageFinished: (_) => _injectSessionAndOpenIfNeeded(), - onWebResourceError: (error) { - if (!mounted) return; - if (error.isForMainFrame == true) { - setState(() { - _error = '${error.errorCode}: ${error.description}'; - }); - } - }, - ), - ); + if (!_useDesktopWebView) { + _mobileController = mobile.WebViewController() + ..setJavaScriptMode(mobile.JavaScriptMode.unrestricted) + ..setBackgroundColor(Colors.transparent) + ..setNavigationDelegate( + mobile.NavigationDelegate( + onProgress: (progress) { + if (!mounted) return; + setState(() => _progress = progress); + }, + onPageFinished: (_) => _injectSessionAndOpenIfNeeded(), + onWebResourceError: (error) { + if (!mounted) return; + if (error.isForMainFrame == true) { + setState(() { + _error = '${error.errorCode}: ${error.description}'; + }); + } + }, + ), + ); + } _prepareAndOpen(); } + @override + void dispose() { + _desktopController?.dispose(); + super.dispose(); + } + Future _prepareAndOpen() async { setState(() { _isPreparing = true; _error = null; _progress = 0; _sessionInjected = false; + if (_useDesktopWebView) { + _desktopController = null; + } }); try { @@ -80,7 +98,7 @@ class _CloudreveFileAppPageState extends State { // 先打开同源页面,再注入 localStorage。 // 这样 Cloudreve 官方前端可以直接复用 App 的 access_token。 - await _controller.loadRequest(_originUri); + await _loadOrigin(); if (!mounted) return; setState(() => _isPreparing = false); @@ -105,7 +123,9 @@ class _CloudreveFileAppPageState extends State { Uri _buildCloudreveHomeUri() { 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( scheme: _originUri.scheme, @@ -121,7 +141,9 @@ class _CloudreveFileAppPageState extends State { } 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('/'); if (index <= 'cloudreve://my'.length) { return 'cloudreve://my'; @@ -138,7 +160,9 @@ class _CloudreveFileAppPageState extends State { Map? user; try { - final response = await ApiService.instance.get>('/user/me'); + final response = await ApiService.instance.get>( + '/user/me', + ); user = Map.from(response); } catch (_) { user = null; @@ -181,15 +205,16 @@ class _CloudreveFileAppPageState extends State { try { if (sessionJson != null) { - final script = ''' + final script = + ''' try { localStorage.setItem('cloudreve_session', ${jsonEncode(sessionJson)}); } catch (e) {} window.location.replace(${jsonEncode(target)}); '''; - await _controller.runJavaScript(script); + await _runJavaScript(script); } else { - await _controller.loadRequest(_targetUri); + await _loadTarget(); } } catch (e) { if (!mounted) return; @@ -199,17 +224,52 @@ class _CloudreveFileAppPageState extends State { Future _openTargetAgain() async { _sessionInjected = true; - await _controller.loadRequest(_targetUri); + await _loadTarget(); + } + + Future _reloadWebView() async { + if (_useDesktopWebView) { + await _desktopController?.reload(); + } else { + await _mobileController?.reload(); + } + } + + Future _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 _loadTarget() async { + if (_useDesktopWebView) { + await _desktopController?.loadUrl( + urlRequest: desktop.URLRequest(url: desktop.WebUri.uri(_targetUri)), + ); + } else { + await _mobileController!.loadRequest(_targetUri); + } + } + + Future _runJavaScript(String source) async { + if (_useDesktopWebView) { + await _desktopController?.evaluateJavascript(source: source); + } else { + await _mobileController!.runJavaScript(source); + } } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: Text( - widget.file.name, - overflow: TextOverflow.ellipsis, - ), + title: Text(widget.file.name, overflow: TextOverflow.ellipsis), actions: [ IconButton( tooltip: '重新打开', @@ -219,7 +279,7 @@ class _CloudreveFileAppPageState extends State { IconButton( tooltip: '刷新', icon: const Icon(Icons.refresh), - onPressed: () => _controller.reload(), + onPressed: _reloadWebView, ), ], bottom: PreferredSize( @@ -260,6 +320,32 @@ class _CloudreveFileAppPageState extends State { ); } - 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!); } } diff --git a/lib/presentation/pages/share/share_link_page.dart b/lib/presentation/pages/share/share_link_page.dart index fe4bb10..d5a2c96 100644 --- a/lib/presentation/pages/share/share_link_page.dart +++ b/lib/presentation/pages/share/share_link_page.dart @@ -1,11 +1,14 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_inappwebview/flutter_inappwebview.dart' as desktop; import 'package:lucide_icons/lucide_icons.dart'; import 'package:provider/provider.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 '../../../services/api_service.dart'; @@ -14,13 +17,13 @@ import '../../providers/download_manager_provider.dart'; import '../../widgets/folder_picker.dart'; import '../../widgets/user_avatar.dart'; +bool get _useDesktopWebView => + !kIsWeb && (Platform.isWindows || Platform.isLinux); + class ShareLinkPage extends StatefulWidget { final ShareLinkCandidate candidate; - const ShareLinkPage({ - super.key, - required this.candidate, - }); + const ShareLinkPage({super.key, required this.candidate}); @override State createState() => _ShareLinkPageState(); @@ -49,7 +52,9 @@ class _ShareLinkPageState extends State { final baseUri = Uri.tryParse(ApiService.instance.dio.options.baseUrl); if (shareUri == null || baseUri == null) return false; 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; } @@ -165,8 +170,11 @@ class _ShareLinkPageState extends State { final info = _info; if (!mounted) return; setState(() { - _singleFile = info == null ? null : ShareLinkService.instance.fileFromShareInfo(info); - _contextHint = _singleFile?.contextHint ?? _contextHint ?? info?.contextHint; + _singleFile = info == null + ? null + : ShareLinkService.instance.fileFromShareInfo(info); + _contextHint = + _singleFile?.contextHint ?? _contextHint ?? info?.contextHint; _fileError = null; _loadingFiles = false; }); @@ -280,9 +288,9 @@ class _ShareLinkPageState extends State { return; } catch (downloadError) { if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('创建下载任务失败:$downloadError')), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('创建下载任务失败:$downloadError'))); return; } } @@ -311,20 +319,17 @@ class _ShareLinkPageState extends State { } final task = await context.read().addDownloadTask( - fileName: fileName, - fileUri: fileUri, - fileSize: fileSize, - ); + fileName: fileName, + fileUri: fileUri, + fileSize: fileSize, + ); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(task == null ? '该文件已在下载队列中' : '已添加到下载队列,正在下载'), - ), + SnackBar(content: Text(task == null ? '该文件已在下载队列中' : '已添加到下载队列,正在下载')), ); } - Future _obtainDownloadUrlFromOfficialPage({ required String fileName, }) async { @@ -332,7 +337,7 @@ class _ShareLinkPageState extends State { if (shareUri == null) return null; final completer = Completer(); - late final WebViewController controller; + mobile.WebViewController? mobileController; BuildContext? dialogContext; Timer? timeoutTimer; @@ -341,42 +346,45 @@ class _ShareLinkPageState extends State { completer.complete(url); } - controller = WebViewController() - ..setJavaScriptMode(JavaScriptMode.unrestricted) - ..addJavaScriptChannel( - 'CloudreveDownloadBridge', - onMessageReceived: (message) { - final url = _extractOfficialDownloadUrl( - message.message, - shareUri: shareUri, - ); - if (url != null && url.isNotEmpty) { - complete(url); - } - }, - ) - ..setNavigationDelegate( - NavigationDelegate( - onPageFinished: (_) async { - await _installOfficialDownloadHook(controller); - }, - onNavigationRequest: (request) { - final url = request.url; - if (_looksLikeDirectDownloadUrl(url, shareUri: shareUri)) { - complete(url); - return NavigationDecision.prevent; - } - return NavigationDecision.navigate; - }, - onUrlChange: (change) { - final url = change.url; - if (url != null && _looksLikeDirectDownloadUrl(url, shareUri: shareUri)) { + if (!_useDesktopWebView) { + mobileController = mobile.WebViewController() + ..setJavaScriptMode(mobile.JavaScriptMode.unrestricted) + ..addJavaScriptChannel( + 'CloudreveDownloadBridge', + onMessageReceived: (message) { + final url = _extractOfficialDownloadUrl( + message.message, + shareUri: shareUri, + ); + if (url != null && url.isNotEmpty) { complete(url); } }, - ), - ) - ..loadRequest(shareUri); + ) + ..setNavigationDelegate( + mobile.NavigationDelegate( + onPageFinished: (_) async { + await _installOfficialDownloadHookForMobile(mobileController!); + }, + onNavigationRequest: (request) { + final url = request.url; + if (_looksLikeDirectDownloadUrl(url, shareUri: shareUri)) { + complete(url); + return mobile.NavigationDecision.prevent; + } + return mobile.NavigationDecision.navigate; + }, + onUrlChange: (change) { + final url = change.url; + if (url != null && + _looksLikeDirectDownloadUrl(url, shareUri: shareUri)) { + complete(url); + } + }, + ), + ) + ..loadRequest(shareUri); + } timeoutTimer = Timer(const Duration(seconds: 24), () => complete(null)); @@ -409,7 +417,9 @@ class _ShareLinkPageState extends State { const SizedBox(height: 10), Text( '不会自动跳转浏览器;浏览器入口只作为手动备用方案。', - style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.hintColor, + ), ), const SizedBox(height: 1), SizedBox( @@ -417,7 +427,32 @@ class _ShareLinkPageState extends State { height: 1, child: Opacity( 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 { onPressed: () => complete(null), child: const Text('取消'), ), - TextButton( - onPressed: _openExternal, - child: const Text('浏览器打开'), - ), + TextButton(onPressed: _openExternal, child: const Text('浏览器打开')), ], ); }, @@ -440,8 +472,18 @@ class _ShareLinkPageState extends State { return result; } - Future _installOfficialDownloadHook(WebViewController controller) async { - const hookScript = r""" + Future _installOfficialDownloadHookForMobile( + 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 () { if (window.__cloudreveAppDownloadHookInstalled) return; window.__cloudreveAppDownloadHookInstalled = true; @@ -449,7 +491,11 @@ class _ShareLinkPageState extends State { function post(value) { try { if (typeof value !== 'string') value = JSON.stringify(value); - CloudreveDownloadBridge.postMessage(value); + if (typeof CloudreveDownloadBridge !== 'undefined' && CloudreveDownloadBridge.postMessage) { + CloudreveDownloadBridge.postMessage(value); + } else if (window.flutter_inappwebview && window.flutter_inappwebview.callHandler) { + window.flutter_inappwebview.callHandler('CloudreveDownloadBridge', value); + } } catch (e) {} } @@ -521,7 +567,7 @@ class _ShareLinkPageState extends State { })(); """; - const clickScript = r""" + static const String _officialDownloadClickScript = r""" (function () { function visible(el) { if (!el) return false; @@ -571,20 +617,13 @@ class _ShareLinkPageState extends State { })(); """; - try { - await controller.runJavaScript(hookScript); - await controller.runJavaScript(clickScript); - } catch (_) { - // 官方页面脚本注入失败时,外层会超时并保留“浏览器打开”。 - } - } - String? _extractOfficialDownloadUrl(String message, {required Uri shareUri}) { final text = message.trim(); if (text.isEmpty) return null; final direct = _normalizeOfficialUrl(text, shareUri: shareUri); - if (direct != null && _looksLikeDirectDownloadUrl(direct, shareUri: shareUri)) { + if (direct != null && + _looksLikeDirectDownloadUrl(direct, shareUri: shareUri)) { return direct; } @@ -601,7 +640,8 @@ class _ShareLinkPageState extends State { if (value is String) { final normalized = _normalizeOfficialUrl(value, shareUri: shareUri); - if (normalized != null && _looksLikeDirectDownloadUrl(normalized, shareUri: shareUri)) { + if (normalized != null && + _looksLikeDirectDownloadUrl(normalized, shareUri: shareUri)) { return normalized; } return null; @@ -618,8 +658,12 @@ class _ShareLinkPageState extends State { if (value is Map) { final navigationUrl = value['__navigation_url']; if (navigationUrl is String) { - final normalized = _normalizeOfficialUrl(navigationUrl, shareUri: shareUri); - if (normalized != null && _looksLikeDirectDownloadUrl(normalized, shareUri: shareUri)) { + final normalized = _normalizeOfficialUrl( + navigationUrl, + shareUri: shareUri, + ); + if (normalized != null && + _looksLikeDirectDownloadUrl(normalized, shareUri: shareUri)) { return normalized; } } @@ -678,7 +722,9 @@ class _ShareLinkPageState extends State { final path = uri.path.toLowerCase(); if (sameHost) { - if (path.startsWith('/s/') || path == '/home' || path.startsWith('/home/')) { + if (path.startsWith('/s/') || + path == '/home' || + path.startsWith('/home/')) { return false; } if (path.contains('/api/v4/file/url')) return false; @@ -706,16 +752,14 @@ class _ShareLinkPageState extends State { setState(() => _saving = true); try { - final isRootShareFolder = ShareLinkService.isShareRootUri( - uri, - shareId: widget.candidate.id, - ) && + final isRootShareFolder = + ShareLinkService.isShareRootUri(uri, shareId: widget.candidate.id) && _files.isNotEmpty; final urisToSave = isRootShareFolder ? _files - .map((file) => file.path) - .where((path) => path.trim().isNotEmpty) - .toList() + .map((file) => file.path) + .where((path) => path.trim().isNotEmpty) + .toList() : [uri]; await ShareLinkService.instance.saveSharedFiles( @@ -728,14 +772,14 @@ class _ShareLinkPageState extends State { ); if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('已转存「$name」到 $destination')), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('已转存「$name」到 $destination'))); } catch (e) { if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('转存失败:$e')), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('转存失败:$e'))); } finally { if (mounted) setState(() => _saving = false); } @@ -760,9 +804,9 @@ class _ShareLinkPageState extends State { children: [ Text( '选择转存位置', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w800, - ), + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w800), ), const SizedBox(height: 12), FolderPicker( @@ -880,9 +924,8 @@ class _ShareLinkPageState extends State { isDense: true, border: OutlineInputBorder(), ), - onSubmitted: (_) => _loadShare( - password: _passwordController.text.trim(), - ), + onSubmitted: (_) => + _loadShare(password: _passwordController.text.trim()), ), ), const SizedBox(width: 10), @@ -890,8 +933,8 @@ class _ShareLinkPageState extends State { onPressed: _loadingInfo ? null : () => _loadShare( - password: _passwordController.text.trim(), - ), + password: _passwordController.text.trim(), + ), child: const Text('解锁'), ), ], @@ -910,13 +953,18 @@ class _ShareLinkPageState extends State { CircleAvatar( radius: 28, 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), Expanded( 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 { return Row( children: [ _isSameOrigin - ? UserAvatar( - userId: ownerId, - displayName: ownerName, - radius: 29, - ) + ? UserAvatar(userId: ownerId, displayName: ownerName, radius: 29) : CircleAvatar( radius: 29, backgroundColor: theme.colorScheme.primaryContainer, @@ -966,7 +1010,9 @@ class _ShareLinkPageState extends State { const SizedBox(height: 4), Text( '向您分享了 ${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 { const SizedBox(width: 8), _StatusBadge( 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 { info.name, maxLines: 2, 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 { runSpacing: 8, children: [ _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) - _MetaChip(icon: LucideIcons.calendar, text: '${_formatDate(info.createdAt!)} 创建'), + _MetaChip( + icon: LucideIcons.calendar, + text: '${_formatDate(info.createdAt!)} 创建', + ), if (info.expires != null) - _MetaChip(icon: LucideIcons.clock, text: '${_formatDate(info.expires!)} 过期'), - if (info.isPrivate) const _MetaChip(icon: LucideIcons.lock, text: '私密分享'), + _MetaChip( + icon: LucideIcons.clock, + text: '${_formatDate(info.expires!)} 过期', + ), + if (info.isPrivate) + const _MetaChip(icon: LucideIcons.lock, text: '私密分享'), ], ), ], @@ -1082,7 +1140,8 @@ class _ShareLinkPageState extends State { } 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) ? info.sourceUri : (file.path.trim().isNotEmpty ? file.path : null); @@ -1108,19 +1167,17 @@ class _ShareLinkPageState extends State { info.name, maxLines: 3, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w900, - ), + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.w900), ), ...[ - const SizedBox(height: 4), - Text( - '${file.size > 0 ? _ShareFileTile.formatSize(file.size) : '分享文件'}${file.updatedAt == null ? '' : ' · ${_formatDate(file.updatedAt!)}'}', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).hintColor, - ), - ), - ], + const SizedBox(height: 4), + Text( + '${file.size > 0 ? _ShareFileTile.formatSize(file.size) : '分享文件'}${file.updatedAt == null ? '' : ' · ${_formatDate(file.updatedAt!)}'}', + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: Theme.of(context).hintColor), + ), + ], ], ), ), @@ -1135,7 +1192,9 @@ class _ShareLinkPageState extends State { _ErrorBox( text: '文件详情读取失败,仍可尝试下载或转存:$_fileError', actionText: sourceUri == null ? null : '重试', - onAction: sourceUri == null ? null : () => _loadSingleFile(sourceUri), + onAction: sourceUri == null + ? null + : () => _loadSingleFile(sourceUri), ), ], const SizedBox(height: 18), @@ -1182,12 +1241,12 @@ class _ShareLinkPageState extends State { onPressed: uri == null || uri.isEmpty || _openingDownload ? null : () => _openDownloadUrl( - uri, - fileName: name, - fileSize: fileSize, - entity: entity, - archive: archive, - ), + uri, + fileName: name, + fileSize: fileSize, + entity: entity, + archive: archive, + ), icon: _openingDownload ? const SizedBox( width: 16, @@ -1248,19 +1307,23 @@ class _ShareLinkPageState extends State { subtitle: '这个分享目录下没有文件。', ) else - ..._files.map((file) => _ShareFileTile( - file: file, - onTap: file.isFolder ? () => _enterFolder(file) : null, - onDownload: file.isFile - ? () => _openDownloadUrl( - file.path, - fileName: file.name, - fileSize: file.size, - entity: file.primaryEntity, - ) - : null, - onSave: _isSameOrigin ? () => _saveSharedUri(file.path, name: file.name) : null, - )), + ..._files.map( + (file) => _ShareFileTile( + file: file, + onTap: file.isFolder ? () => _enterFolder(file) : null, + onDownload: file.isFile + ? () => _openDownloadUrl( + file.path, + fileName: file.name, + fileSize: file.size, + entity: file.primaryEntity, + ) + : null, + onSave: _isSameOrigin + ? () => _saveSharedUri(file.path, name: file.name) + : null, + ), + ), ], ); } @@ -1271,7 +1334,9 @@ class _ShareLinkPageState extends State { margin: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.55), + color: theme.colorScheme.surfaceContainerHighest.withValues( + alpha: 0.55, + ), borderRadius: BorderRadius.circular(14), ), child: SingleChildScrollView( @@ -1287,12 +1352,17 @@ class _ShareLinkPageState extends State { onTap: isLast ? null : () => _jumpToBreadcrumb(index), borderRadius: BorderRadius.circular(999), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 5, + ), child: Text( item.title, style: TextStyle( 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}); } +class _OfficialDownloadDesktopWebView extends StatefulWidget { + final Uri shareUri; + final ValueChanged 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 _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 { final ShareLinkFile file; final VoidCallback? onTap; @@ -1426,7 +1583,9 @@ class _ShareFileTile extends StatelessWidget { size /= 1024; 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]}'; } @@ -1440,10 +1599,7 @@ class _MetaChip extends StatelessWidget { final IconData icon; final String text; - const _MetaChip({ - required this.icon, - required this.text, - }); + const _MetaChip({required this.icon, required this.text}); @override Widget build(BuildContext context) { @@ -1498,11 +1654,7 @@ class _ErrorBox extends StatelessWidget { final String? actionText; final VoidCallback? onAction; - const _ErrorBox({ - required this.text, - this.actionText, - this.onAction, - }); + const _ErrorBox({required this.text, this.actionText, this.onAction}); @override Widget build(BuildContext context) { @@ -1525,10 +1677,7 @@ class _ErrorBox extends StatelessWidget { const SizedBox(height: 8), Align( alignment: Alignment.centerRight, - child: TextButton( - onPressed: onAction, - child: Text(actionText!), - ), + child: TextButton(onPressed: onAction, child: Text(actionText!)), ), ], ], @@ -1573,16 +1722,11 @@ class _EmptyState extends StatelessWidget { Text( subtitle, textAlign: TextAlign.center, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.hintColor, - ), + style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor), ), if (actionText != null && onAction != null) ...[ const SizedBox(height: 14), - OutlinedButton( - onPressed: onAction, - child: Text(actionText!), - ), + OutlinedButton(onPressed: onAction, child: Text(actionText!)), ], ], ), diff --git a/lib/presentation/widgets/announcement_dialog.dart b/lib/presentation/widgets/announcement_dialog.dart index 4b086e1..6c53e10 100644 --- a/lib/presentation/widgets/announcement_dialog.dart +++ b/lib/presentation/widgets/announcement_dialog.dart @@ -1,7 +1,13 @@ import 'dart:convert'; +import 'dart:io'; +import 'package:flutter/foundation.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 { final String title; @@ -26,11 +32,8 @@ class AnnouncementDialog extends StatefulWidget { await showDialog( context: context, barrierDismissible: true, - builder: (_) => AnnouncementDialog( - title: title, - html: html, - baseUrl: baseUrl, - ), + builder: (_) => + AnnouncementDialog(title: title, html: html, baseUrl: baseUrl), ); } @@ -39,35 +42,39 @@ class AnnouncementDialog extends StatefulWidget { } class _AnnouncementDialogState extends State { - late final WebViewController _controller; + mobile.WebViewController? _mobileController; + desktop.InAppWebViewController? _desktopController; + late final String _html; + late final String _baseUrl; bool _loading = true; @override void initState() { super.initState(); - _controller = WebViewController() - ..setJavaScriptMode(JavaScriptMode.unrestricted) - ..setBackgroundColor(Colors.transparent) - ..setNavigationDelegate( - NavigationDelegate( - onPageFinished: (_) { - if (mounted) setState(() => _loading = false); - }, - ), - ); + final origin = Uri.parse(widget.baseUrl).origin; + _html = _wrapHtml(widget.html); + _baseUrl = '$origin/'; - _load(); + if (!_useDesktopWebView) { + _mobileController = mobile.WebViewController() + ..setJavaScriptMode(mobile.JavaScriptMode.unrestricted) + ..setBackgroundColor(Colors.transparent) + ..setNavigationDelegate( + mobile.NavigationDelegate( + onPageFinished: (_) { + if (mounted) setState(() => _loading = false); + }, + ), + ) + ..loadHtmlString(_html, baseUrl: _baseUrl); + } } - Future _load() async { - final origin = Uri.parse(widget.baseUrl).origin; - final html = _wrapHtml(widget.html); - - await _controller.loadHtmlString( - html, - baseUrl: '$origin/', - ); + @override + void dispose() { + _desktopController?.dispose(); + super.dispose(); } String _wrapHtml(String body) { @@ -162,7 +169,7 @@ $body Expanded( child: Stack( children: [ - WebViewWidget(controller: _controller), + _buildWebView(), if (_loading) 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!); + } } diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt index 458713e..4c535cf 100644 --- a/windows/CMakeLists.txt +++ b/windows/CMakeLists.txt @@ -32,21 +32,56 @@ set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") # Use Unicode for all projects. add_definitions(-DUNICODE -D_UNICODE) -# flutter_inappwebview_windows requires NuGet; help CMake find it -if(NOT DEFINED NUGET OR NUGET STREQUAL "NUGET-NOTFOUND") - find_program(NUGET nuget PATHS - "$ENV{USERPROFILE}\\.local\\bin" - "$ENV{LOCALAPPDATA}\\Microsoft\\WinGet\\Links" - "C:/ProgramData/chocolatey/bin" - NO_DEFAULT_PATH - ) - find_program(NUGET nuget) - if(NUGET AND NOT NUGET STREQUAL "NUGET-NOTFOUND") - message(STATUS "Found NuGet: ${NUGET}") - set(NUGET "${NUGET}" CACHE FILEPATH "nuget executable" FORCE) +# flutter_inappwebview_windows requires NuGet. The plugin's own CMake only +# calls find_program(NUGET nuget), so seed the cache with an absolute path. +if(DEFINED NUGET AND NOT NUGET STREQUAL "" AND NOT NUGET STREQUAL "NUGET-NOTFOUND") + if(NOT EXISTS "${NUGET}") + unset(NUGET CACHE) + unset(NUGET) 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" + ) + foreach(_NUGET_CANDIDATE IN LISTS _NUGET_CANDIDATES) + 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}") + set(NUGET "${NUGET}" CACHE FILEPATH "nuget executable" FORCE) +else() + message(WARNING "NuGet executable was not found. Windows WebView builds may fail.") +endif() + # Compilation settings that should be applied to most targets. # # Be cautious about adding new options here, as plugins use this function by @@ -125,6 +160,12 @@ ENSURE_WINDOWS_ARCHIVE( # Generated plugin build rules, which manage building the plugins and adding # 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) if(MSVC)