修复处理内容包括公告 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: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<CloudreveFileAppPage> {
late final WebViewController _controller;
mobile.WebViewController? _mobileController;
desktop.InAppWebViewController? _desktopController;
bool _isPreparing = true;
bool _sessionInjected = false;
@@ -40,36 +47,47 @@ class _CloudreveFileAppPageState extends State<CloudreveFileAppPage> {
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<void> _prepareAndOpen() async {
setState(() {
_isPreparing = true;
_error = null;
_progress = 0;
_sessionInjected = false;
if (_useDesktopWebView) {
_desktopController = null;
}
});
try {
@@ -80,7 +98,7 @@ class _CloudreveFileAppPageState extends State<CloudreveFileAppPage> {
// 先打开同源页面,再注入 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<CloudreveFileAppPage> {
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<CloudreveFileAppPage> {
}
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<CloudreveFileAppPage> {
Map<String, dynamic>? user;
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);
} catch (_) {
user = null;
@@ -181,15 +205,16 @@ class _CloudreveFileAppPageState extends State<CloudreveFileAppPage> {
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<CloudreveFileAppPage> {
Future<void> _openTargetAgain() async {
_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
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<CloudreveFileAppPage> {
IconButton(
tooltip: '刷新',
icon: const Icon(Icons.refresh),
onPressed: () => _controller.reload(),
onPressed: _reloadWebView,
),
],
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!);
}
}
+311 -167
View File
@@ -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<ShareLinkPage> createState() => _ShareLinkPageState();
@@ -49,7 +52,9 @@ class _ShareLinkPageState extends State<ShareLinkPage> {
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<ShareLinkPage> {
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<ShareLinkPage> {
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<ShareLinkPage> {
}
final task = await context.read<DownloadManagerProvider>().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<String?> _obtainDownloadUrlFromOfficialPage({
required String fileName,
}) async {
@@ -332,7 +337,7 @@ class _ShareLinkPageState extends State<ShareLinkPage> {
if (shareUri == null) return null;
final completer = Completer<String?>();
late final WebViewController controller;
mobile.WebViewController? mobileController;
BuildContext? dialogContext;
Timer? timeoutTimer;
@@ -341,42 +346,45 @@ class _ShareLinkPageState extends State<ShareLinkPage> {
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<ShareLinkPage> {
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<ShareLinkPage> {
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<ShareLinkPage> {
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<ShareLinkPage> {
return result;
}
Future<void> _installOfficialDownloadHook(WebViewController controller) async {
const hookScript = r"""
Future<void> _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<ShareLinkPage> {
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<ShareLinkPage> {
})();
""";
const clickScript = r"""
static const String _officialDownloadClickScript = r"""
(function () {
function visible(el) {
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}) {
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<ShareLinkPage> {
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<ShareLinkPage> {
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<ShareLinkPage> {
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<ShareLinkPage> {
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()
: <String>[uri];
await ShareLinkService.instance.saveSharedFiles(
@@ -728,14 +772,14 @@ class _ShareLinkPageState extends State<ShareLinkPage> {
);
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<ShareLinkPage> {
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<ShareLinkPage> {
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<ShareLinkPage> {
onPressed: _loadingInfo
? null
: () => _loadShare(
password: _passwordController.text.trim(),
),
password: _passwordController.text.trim(),
),
child: const Text('解锁'),
),
],
@@ -910,13 +953,18 @@ class _ShareLinkPageState extends State<ShareLinkPage> {
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<ShareLinkPage> {
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<ShareLinkPage> {
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<ShareLinkPage> {
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<ShareLinkPage> {
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<ShareLinkPage> {
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<ShareLinkPage> {
}
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<ShareLinkPage> {
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<ShareLinkPage> {
_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<ShareLinkPage> {
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<ShareLinkPage> {
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<ShareLinkPage> {
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<ShareLinkPage> {
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<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 {
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!)),
],
],
),
@@ -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<void>(
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<AnnouncementDialog> {
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<void> _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!);
}
}