修复处理内容包括公告 WebView、文件预览页、分享页兜底获取下载链接的隐藏 WebView。
This commit is contained in:
@@ -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!)),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user