Files
app/lib/presentation/pages/share/share_link_page.dart
T

1736 lines
52 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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' as mobile;
import '../../../core/utils/app_logger.dart';
import '../../../services/api_service.dart';
import '../../../services/share_link_service.dart';
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});
@override
State<ShareLinkPage> createState() => _ShareLinkPageState();
}
class _ShareLinkPageState extends State<ShareLinkPage> {
final TextEditingController _passwordController = TextEditingController();
final List<_ShareBreadcrumb> _breadcrumbs = <_ShareBreadcrumb>[];
ShareLinkInfo? _info;
ShareLinkFile? _singleFile;
List<ShareLinkFile> _files = const [];
Object? _error;
Object? _fileError;
String? _contextHint;
String? _currentUri;
bool _loadingInfo = true;
bool _loadingFiles = false;
bool _openingDownload = false;
bool _saving = false;
String get _displayUrl => _info?.url ?? widget.candidate.url;
bool get _isSameOrigin {
final shareUri = Uri.tryParse(widget.candidate.url);
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',
);
return same;
}
@override
void initState() {
super.initState();
_passwordController.text = widget.candidate.password ?? '';
_loadShare(password: widget.candidate.password);
}
@override
void dispose() {
_passwordController.dispose();
super.dispose();
}
Future<void> _loadShare({String? password}) async {
setState(() {
_loadingInfo = true;
_error = null;
_fileError = null;
_files = const [];
_singleFile = null;
_contextHint = null;
_currentUri = null;
_breadcrumbs.clear();
});
try {
final info = await ShareLinkService.instance.getShareInfo(
widget.candidate,
password: password?.trim().isEmpty == true ? null : password,
);
if (!mounted) return;
setState(() {
_info = info;
_contextHint = info.contextHint ?? _contextHint;
_loadingInfo = false;
});
final sourceUri = info.sourceUri;
if (info.expired || !info.unlocked || sourceUri == null) return;
if (info.isFolder) {
await _loadFolder(sourceUri, resetBreadcrumbs: true, title: info.name);
} else {
await _loadSingleFile(sourceUri);
}
} catch (e) {
if (!mounted) return;
setState(() {
_error = e;
_loadingInfo = false;
});
}
}
Future<void> _loadSingleFile(String uri) async {
setState(() {
_loadingFiles = true;
_fileError = null;
_currentUri = uri;
});
// Cloudreve 官方分享页会把 /s/{id} 重定向到
// /home?path=cloudreve://{id}@share,然后按普通文件列表读取 share 文件系统。
// 单文件分享也要先读取 share 根目录,拿到服务端返回的真实文件 path/context_hint
// 再用这个 path 调 /file/url。直接拿 share 根 URI 下载会得到 40081。
try {
final list = await ShareLinkService.instance.listSharedFiles(
uri: uri,
contextHint: _contextHint,
);
if (list.files.isNotEmpty) {
final file = list.files.first;
if (!mounted) return;
setState(() {
_singleFile = file;
_contextHint = file.contextHint ?? list.contextHint ?? _contextHint;
_currentUri = file.path.isNotEmpty ? file.path : uri;
_loadingFiles = false;
});
return;
}
if (list.contextHint != null && list.contextHint!.isNotEmpty) {
_contextHint = list.contextHint;
}
} catch (_) {
// 某些服务端不允许对单文件分享根目录执行 /file,继续走 /file/info 降级。
}
try {
final file = await ShareLinkService.instance.getSharedFileInfo(
uri: uri,
contextHint: _contextHint,
shareId: widget.candidate.id,
password: widget.candidate.password,
);
if (!mounted) return;
setState(() {
_singleFile = file;
_contextHint = file.contextHint ?? _contextHint;
_currentUri = file.path.isNotEmpty ? file.path : uri;
_loadingFiles = false;
});
} catch (e) {
// 部分服务端在公开分享上下文中不允许读取 /file/info
// 但仍允许通过 /file/url 下载或 /file/move 转存。这里降级为分享信息卡片,
// 避免页面出现大块错误提示。
final info = _info;
if (!mounted) return;
setState(() {
_singleFile = info == null
? null
: ShareLinkService.instance.fileFromShareInfo(info);
_contextHint =
_singleFile?.contextHint ?? _contextHint ?? info?.contextHint;
_fileError = null;
_loadingFiles = false;
});
}
}
Future<void> _loadFolder(
String uri, {
bool resetBreadcrumbs = false,
String? title,
}) async {
setState(() {
_loadingFiles = true;
_fileError = null;
_currentUri = uri;
if (resetBreadcrumbs) {
_breadcrumbs
..clear()
..add(_ShareBreadcrumb(title: title ?? '分享目录', uri: uri));
}
});
try {
final result = await ShareLinkService.instance.listSharedFiles(
uri: uri,
contextHint: _contextHint,
);
if (!mounted) return;
setState(() {
_files = result.files;
_contextHint = result.contextHint ?? _contextHint;
_loadingFiles = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_fileError = e;
_loadingFiles = false;
});
}
}
Future<void> _refresh() async {
final info = _info;
if (info == null) {
await _loadShare(password: _passwordController.text.trim());
return;
}
final uri = _currentUri ?? info.sourceUri;
if (uri == null || !info.unlocked || info.expired) {
await _loadShare(password: _passwordController.text.trim());
return;
}
if (info.isFolder) {
await _loadFolder(uri);
} else {
await _loadSingleFile(uri);
}
}
Future<void> _openExternal() async {
final uri = Uri.tryParse(_displayUrl);
if (uri == null) return;
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
Future<void> _openDownloadUrl(
String uri, {
required String fileName,
required int fileSize,
String? entity,
bool archive = false,
}) async {
if (_openingDownload) return;
setState(() => _openingDownload = true);
try {
final result = await ShareLinkService.instance.createShareDownloadUrl(
uri: uri,
contextHint: _contextHint,
shareId: widget.candidate.id,
password: widget.candidate.password,
entity: entity,
fileName: fileName,
archive: archive,
);
await _enqueueInAppDownload(
result.url,
fileName: fileName,
fileSize: fileSize,
fileUri: uri,
);
} catch (e) {
if (!mounted) return;
final fallbackUrl = await _obtainDownloadUrlFromOfficialPage(
fileName: fileName,
);
if (fallbackUrl != null && fallbackUrl.isNotEmpty) {
try {
await _enqueueInAppDownload(
fallbackUrl,
fileName: fileName,
fileSize: fileSize,
fileUri: uri,
);
return;
} catch (downloadError) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('创建下载任务失败:$downloadError')));
return;
}
}
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('获取下载链接失败:$e'),
action: SnackBarAction(label: '浏览器打开', onPressed: _openExternal),
),
);
} finally {
if (mounted) setState(() => _openingDownload = false);
}
}
Future<void> _enqueueInAppDownload(
String rawUrl, {
required String fileName,
required int fileSize,
required String fileUri,
}) async {
final downloadUri = Uri.tryParse(rawUrl);
if (downloadUri == null || !downloadUri.hasScheme) {
throw Exception('下载链接格式错误');
}
final task = await context.read<DownloadManagerProvider>().addDownloadTask(
fileName: fileName,
fileUri: fileUri,
fileSize: fileSize,
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(task == null ? '该文件已在下载队列中' : '已添加到下载队列,正在下载')),
);
}
Future<String?> _obtainDownloadUrlFromOfficialPage({
required String fileName,
}) async {
final shareUri = Uri.tryParse(widget.candidate.url);
if (shareUri == null) return null;
final completer = Completer<String?>();
mobile.WebViewController? mobileController;
BuildContext? dialogContext;
Timer? timeoutTimer;
void complete(String? url) {
if (completer.isCompleted) return;
completer.complete(url);
}
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);
}
},
)
..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));
completer.future.then((url) {
final ctx = dialogContext;
if (!mounted && !context.mounted) return;
if (ctx != null && Navigator.of(ctx, rootNavigator: true).canPop()) {
Navigator.of(ctx, rootNavigator: true).pop(url);
}
});
final result = await showDialog<String?>(
context: context,
barrierDismissible: false,
builder: (context) {
dialogContext = context;
final theme = Theme.of(context);
return AlertDialog(
title: const Text('正在获取下载链接'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'正在按 Cloudreve 官方分享页面流程获取「$fileName」的真实下载地址,获取成功后会加入应用内下载任务。',
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 14),
const LinearProgressIndicator(),
const SizedBox(height: 10),
Text(
'不会自动跳转浏览器;浏览器入口只作为手动备用方案。',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.hintColor,
),
),
const SizedBox(height: 1),
SizedBox(
width: 1,
height: 1,
child: Opacity(
opacity: 0.01,
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!),
),
),
],
),
actions: [
TextButton(
onPressed: () => complete(null),
child: const Text('取消'),
),
TextButton(onPressed: _openExternal, child: const Text('浏览器打开')),
],
);
},
);
timeoutTimer.cancel();
return result;
}
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;
function post(value) {
try {
if (typeof value !== 'string') value = JSON.stringify(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) {}
}
function inspectText(text) {
if (!text) return;
try {
post(text);
} catch (e) {}
}
var rawFetch = window.fetch;
if (rawFetch) {
window.fetch = function () {
var req = arguments[0];
var reqUrl = '';
try { reqUrl = String((req && req.url) || req || ''); } catch (e) {}
return rawFetch.apply(this, arguments).then(function (resp) {
try {
var respUrl = String(resp && resp.url ? resp.url : reqUrl);
if (respUrl.indexOf('/api/v4/file/url') >= 0 || reqUrl.indexOf('/api/v4/file/url') >= 0) {
resp.clone().text().then(inspectText).catch(function () {});
}
} catch (e) {}
return resp;
});
};
}
var RawXHR = window.XMLHttpRequest;
if (RawXHR) {
var rawXHROpen = RawXHR.prototype.open;
var rawXHRSend = RawXHR.prototype.send;
RawXHR.prototype.open = function (method, url) {
this.__cloudreveRequestUrl = String(url || '');
return rawXHROpen.apply(this, arguments);
};
RawXHR.prototype.send = function () {
try {
this.addEventListener('load', function () {
try {
var url = String(this.responseURL || this.__cloudreveRequestUrl || '');
if (url.indexOf('/api/v4/file/url') >= 0) {
inspectText(String(this.responseText || ''));
}
} catch (e) {}
});
} catch (e) {}
return rawXHRSend.apply(this, arguments);
};
}
var rawOpen = window.open;
window.open = function (url) {
try { post({ __navigation_url: String(url || '') }); } catch (e) {}
return rawOpen ? rawOpen.apply(this, arguments) : null;
};
document.addEventListener('click', function (event) {
try {
var target = event.target;
var href = '';
while (target && !href) {
href = target.href || target.getAttribute && target.getAttribute('href') || '';
target = target.parentElement;
}
if (href) post({ __navigation_url: String(href) });
} catch (e) {}
}, true);
})();
""";
static const String _officialDownloadClickScript = r"""
(function () {
function visible(el) {
if (!el) return false;
var style = window.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false;
var rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
}
function clickDownload() {
var nodes = Array.prototype.slice.call(document.querySelectorAll('button,a,[role="button"],div,span'));
var best = null;
for (var i = 0; i < nodes.length; i++) {
var el = nodes[i];
var text = [
el.innerText || '',
el.textContent || '',
el.getAttribute && el.getAttribute('aria-label') || '',
el.getAttribute && el.getAttribute('title') || '',
el.className || ''
].join(' ').trim();
if (!text || (text.indexOf('下载') < 0 && text.toLowerCase().indexOf('download') < 0)) continue;
if (el.disabled || el.getAttribute('aria-disabled') === 'true') continue;
best = el;
break;
}
if (best) {
try {
best.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
best.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true }));
best.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
} catch (e) {
best.click();
}
return true;
}
return false;
}
var tries = 0;
var timer = setInterval(function () {
tries++;
if (clickDownload() || tries > 30) {
clearInterval(timer);
}
}, 500);
})();
""";
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)) {
return direct;
}
try {
final decoded = jsonDecode(text);
return _walkOfficialPayloadForUrl(decoded, shareUri: shareUri);
} catch (_) {
return null;
}
}
String? _walkOfficialPayloadForUrl(dynamic value, {required Uri shareUri}) {
if (value == null) return null;
if (value is String) {
final normalized = _normalizeOfficialUrl(value, shareUri: shareUri);
if (normalized != null &&
_looksLikeDirectDownloadUrl(normalized, shareUri: shareUri)) {
return normalized;
}
return null;
}
if (value is List) {
for (final item in value) {
final url = _walkOfficialPayloadForUrl(item, shareUri: shareUri);
if (url != null) return url;
}
return null;
}
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)) {
return normalized;
}
}
const priorityKeys = [
'url',
'download_url',
'downloadUrl',
'href',
'src',
'signed_url',
'signedUrl',
'link',
'urls',
'data',
];
for (final key in priorityKeys) {
if (!value.containsKey(key)) continue;
final url = _walkOfficialPayloadForUrl(value[key], shareUri: shareUri);
if (url != null) return url;
}
for (final item in value.values) {
final url = _walkOfficialPayloadForUrl(item, shareUri: shareUri);
if (url != null) return url;
}
}
return null;
}
String? _normalizeOfficialUrl(String value, {required Uri shareUri}) {
final text = value.trim();
if (text.isEmpty) return null;
final uri = Uri.tryParse(text);
if (uri == null) return null;
if (uri.hasScheme && (uri.scheme == 'http' || uri.scheme == 'https')) {
return uri.toString();
}
if (text.startsWith('/')) {
return shareUri.replace(path: text, query: '', fragment: '').toString();
}
return null;
}
bool _looksLikeDirectDownloadUrl(String value, {required Uri shareUri}) {
final uri = Uri.tryParse(value);
if (uri == null || !uri.hasScheme) return false;
if (uri.scheme != 'http' && uri.scheme != 'https') return false;
final sameHost = uri.host == shareUri.host;
final path = uri.path.toLowerCase();
if (sameHost) {
if (path.startsWith('/s/') ||
path == '/home' ||
path.startsWith('/home/')) {
return false;
}
if (path.contains('/api/v4/file/url')) return false;
if (path.contains('/api/v4/share/info')) return false;
}
final url = uri.toString().toLowerCase();
return !sameHost ||
path.contains('/download') ||
path.contains('/api/v4/file/download') ||
path.contains('/api/v4/file/source') ||
url.contains('response-content-disposition') ||
url.contains('x-amz-signature') ||
url.contains('x-oss-signature') ||
url.contains('signature=') ||
url.contains('sign=') ||
url.contains('token=');
}
Future<void> _saveSharedUri(String uri, {required String name}) async {
if (_saving) return;
final destination = await _pickDestination();
if (destination == null) return;
setState(() => _saving = true);
try {
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()
: <String>[uri];
await ShareLinkService.instance.saveSharedFiles(
uris: urisToSave,
destination: destination,
contextHint: _contextHint,
shareId: widget.candidate.id,
password: widget.candidate.password,
fileName: name,
);
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('已转存「$name」到 $destination')));
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('转存失败:$e')));
} finally {
if (mounted) setState(() => _saving = false);
}
}
Future<String?> _pickDestination() {
return showModalBottomSheet<String>(
context: context,
isScrollControlled: true,
useSafeArea: true,
showDragHandle: true,
builder: (context) {
return Padding(
padding: EdgeInsets.only(
left: 16,
right: 16,
bottom: MediaQuery.of(context).viewInsets.bottom + 16,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'选择转存位置',
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w800),
),
const SizedBox(height: 12),
FolderPicker(
currentPath: '/',
maxVisibleItems: 7,
onFolderSelected: (path) => Navigator.of(context).pop(path),
),
],
),
);
},
);
}
Future<void> _enterFolder(ShareLinkFile file) async {
if (!file.isFolder || file.path.isEmpty) return;
_breadcrumbs.add(_ShareBreadcrumb(title: file.name, uri: file.path));
await _loadFolder(file.path);
}
Future<void> _jumpToBreadcrumb(int index) async {
if (index < 0 || index >= _breadcrumbs.length) return;
final crumb = _breadcrumbs[index];
_breadcrumbs.removeRange(index + 1, _breadcrumbs.length);
await _loadFolder(crumb.uri);
}
Future<bool> _handleBack() async {
if (_breadcrumbs.length > 1) {
final target = _breadcrumbs[_breadcrumbs.length - 2];
_breadcrumbs.removeLast();
await _loadFolder(target.uri);
return false;
}
return true;
}
@override
Widget build(BuildContext context) {
return PopScope(
canPop: _breadcrumbs.length <= 1,
onPopInvokedWithResult: (didPop, result) async {
if (!didPop) {
await _handleBack();
}
},
child: Scaffold(
appBar: AppBar(
title: const Text('文件分享'),
actions: [
IconButton(
tooltip: '浏览器打开',
icon: const Icon(LucideIcons.externalLink),
onPressed: _openExternal,
),
IconButton(
tooltip: '刷新',
icon: const Icon(Icons.refresh),
onPressed: _refresh,
),
],
),
body: RefreshIndicator(
onRefresh: _refresh,
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(16, 10, 16, 24),
children: [
_buildHeader(context),
const SizedBox(height: 14),
_buildContent(context),
],
),
),
),
);
}
Widget _buildHeader(BuildContext context) {
final theme = Theme.of(context);
final info = _info;
return Card(
elevation: 0,
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.55),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(22)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (_loadingInfo && info == null)
const LinearProgressIndicator()
else if (info != null)
_buildOwnerBlock(context, info)
else
_buildLoadingOwnerFallback(context),
if (info != null) ...[
const SizedBox(height: 14),
_buildShareInfoBlock(context, info),
],
if (_error != null) ...[
const SizedBox(height: 12),
_ErrorBox(text: '分享信息读取失败:$_error'),
],
if (_shouldShowPasswordInput(info)) ...[
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: TextField(
controller: _passwordController,
decoration: const InputDecoration(
labelText: '分享密码',
isDense: true,
border: OutlineInputBorder(),
),
onSubmitted: (_) =>
_loadShare(password: _passwordController.text.trim()),
),
),
const SizedBox(width: 10),
FilledButton(
onPressed: _loadingInfo
? null
: () => _loadShare(
password: _passwordController.text.trim(),
),
child: const Text('解锁'),
),
],
),
],
],
),
),
);
}
Widget _buildLoadingOwnerFallback(BuildContext context) {
final theme = Theme.of(context);
return Row(
children: [
CircleAvatar(
radius: 28,
backgroundColor: theme.colorScheme.primaryContainer,
child: Icon(
Icons.ios_share,
color: theme.colorScheme.onPrimaryContainer,
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
'分享链接',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800,
),
),
),
],
);
}
Widget _buildOwnerBlock(BuildContext context, ShareLinkInfo info) {
final theme = Theme.of(context);
final ownerName = info.ownerName?.trim().isNotEmpty == true
? info.ownerName!.trim()
: '匿名用户';
final ownerId = info.ownerId ?? '';
return Row(
children: [
_isSameOrigin
? UserAvatar(userId: ownerId, displayName: ownerName, radius: 29)
: CircleAvatar(
radius: 29,
backgroundColor: theme.colorScheme.primaryContainer,
child: Text(
'',
style: TextStyle(
color: theme.colorScheme.onPrimaryContainer,
fontWeight: FontWeight.w900,
fontSize: 20,
),
),
),
const SizedBox(width: 13),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
ownerName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 4),
Text(
'向您分享了 ${info.isFolder ? '一个文件夹' : '一个文件'}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.hintColor,
),
),
],
),
),
const SizedBox(width: 8),
_StatusBadge(
text: info.expired ? '已过期' : '有效',
color: info.expired
? theme.colorScheme.error
: theme.colorScheme.primary,
),
],
);
}
Widget _buildShareInfoBlock(BuildContext context, ShareLinkInfo info) {
final theme = Theme.of(context);
final file = _singleFile;
final sizeText = file != null && file.isFile && file.size > 0
? _ShareFileTile.formatSize(file.size)
: null;
return Container(
padding: const EdgeInsets.all(13),
decoration: BoxDecoration(
color: theme.colorScheme.surface.withValues(alpha: 0.72),
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(
info.isFolder ? LucideIcons.folder : LucideIcons.file,
size: 22,
color: theme.colorScheme.primary,
),
const SizedBox(width: 8),
Expanded(
child: Text(
info.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w900,
),
),
),
],
),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_MetaChip(icon: LucideIcons.eye, text: '${info.visited} 次访问'),
if (sizeText != null)
_MetaChip(icon: Icons.sd_storage_outlined, text: sizeText),
if (info.createdAt != null)
_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: '私密分享'),
],
),
],
),
);
}
bool _shouldShowPasswordInput(ShareLinkInfo? info) {
if (info == null) return _error != null;
return info.isPrivate && !info.unlocked;
}
Widget _buildContent(BuildContext context) {
final info = _info;
if (_loadingInfo && info == null) {
return const Padding(
padding: EdgeInsets.only(top: 80),
child: Center(child: CircularProgressIndicator()),
);
}
if (info == null) {
return _EmptyState(
icon: LucideIcons.link,
title: '无法打开分享',
subtitle: '请检查链接是否正确,或输入分享密码后重试。',
actionText: '浏览器打开',
onAction: _openExternal,
);
}
if (info.expired) {
return const _EmptyState(
icon: LucideIcons.clock,
title: '分享已过期',
subtitle: '这个分享链接已经失效。',
);
}
if (info.isPrivate && !info.unlocked) {
return const _EmptyState(
icon: LucideIcons.lock,
title: '需要分享密码',
subtitle: '输入正确的分享密码后即可查看文件。',
);
}
if (info.isFile) {
return _buildSingleFileCard(context, info);
}
return _buildFolderList(context, info);
}
Widget _buildSingleFileCard(BuildContext context, ShareLinkInfo info) {
final file =
_singleFile ?? ShareLinkService.instance.fileFromShareInfo(info);
final sourceUri = (info.sourceUri?.trim().isNotEmpty == true)
? info.sourceUri
: (file.path.trim().isNotEmpty ? file.path : null);
final primaryEntity = file.primaryEntity;
return Card(
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Padding(
padding: const EdgeInsets.all(18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
const Icon(LucideIcons.file, size: 34),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
info.name,
maxLines: 3,
overflow: TextOverflow.ellipsis,
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),
),
],
],
),
),
],
),
if (_loadingFiles) ...[
const SizedBox(height: 14),
const LinearProgressIndicator(),
],
if (_fileError != null) ...[
const SizedBox(height: 14),
_ErrorBox(
text: '文件详情读取失败,仍可尝试下载或转存:$_fileError',
actionText: sourceUri == null ? null : '重试',
onAction: sourceUri == null
? null
: () => _loadSingleFile(sourceUri),
),
],
const SizedBox(height: 18),
_buildActionButtons(
uri: sourceUri,
name: info.name,
fileSize: file.size,
entity: primaryEntity,
archive: false,
),
],
),
),
);
}
Widget _buildActionButtons({
required String? uri,
required String name,
required int fileSize,
String? entity,
bool archive = false,
}) {
return Row(
children: [
Expanded(
child: FilledButton.tonalIcon(
onPressed: uri == null || uri.isEmpty || _saving || !_isSameOrigin
? null
: () => _saveSharedUri(uri, name: name),
icon: _saving
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.drive_folder_upload_outlined, size: 18),
label: const Text('转存'),
),
),
const SizedBox(width: 10),
Expanded(
child: FilledButton.icon(
onPressed: uri == null || uri.isEmpty || _openingDownload
? null
: () => _openDownloadUrl(
uri,
fileName: name,
fileSize: fileSize,
entity: entity,
archive: archive,
),
icon: _openingDownload
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(LucideIcons.download, size: 18),
label: const Text('下载'),
),
),
],
);
}
Widget _buildFolderList(BuildContext context, ShareLinkInfo info) {
final theme = Theme.of(context);
final sourceUri = _currentUri ?? info.sourceUri;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (_breadcrumbs.isNotEmpty) _buildBreadcrumbs(context),
Row(
children: [
Expanded(
child: Text(
'文件列表',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w900,
),
),
),
],
),
const SizedBox(height: 10),
_buildActionButtons(
uri: sourceUri,
name: info.name,
fileSize: 0,
archive: true,
),
const SizedBox(height: 12),
if (_loadingFiles)
const Padding(
padding: EdgeInsets.symmetric(vertical: 32),
child: Center(child: CircularProgressIndicator()),
)
else if (_fileError != null)
_ErrorBox(
text: '文件列表读取失败:$_fileError',
actionText: '重试',
onAction: sourceUri == null ? null : () => _loadFolder(sourceUri),
)
else if (_files.isEmpty)
const _EmptyState(
icon: LucideIcons.folderOpen,
title: '文件夹为空',
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,
),
),
],
);
}
Widget _buildBreadcrumbs(BuildContext context) {
final theme = Theme.of(context);
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withValues(
alpha: 0.55,
),
borderRadius: BorderRadius.circular(14),
),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: List.generate(_breadcrumbs.length, (index) {
final item = _breadcrumbs[index];
final isLast = index == _breadcrumbs.length - 1;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
InkWell(
onTap: isLast ? null : () => _jumpToBreadcrumb(index),
borderRadius: BorderRadius.circular(999),
child: Padding(
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,
),
),
),
),
if (!isLast)
Icon(Icons.chevron_right, size: 18, color: theme.hintColor),
],
);
}),
),
),
);
}
static String _formatDate(DateTime value) {
final local = value.toLocal();
return '${local.year}-${local.month.toString().padLeft(2, '0')}-${local.day.toString().padLeft(2, '0')}';
}
}
class _ShareBreadcrumb {
final String title;
final String uri;
const _ShareBreadcrumb({required this.title, required this.uri});
}
class _OfficialDownloadDesktopWebView extends StatefulWidget {
final Uri shareUri;
final ValueChanged<String> onBridgeMessage;
final bool Function(String url) onNavigationUrl;
final String buildHookScript;
final String buildClickScript;
const _OfficialDownloadDesktopWebView({
required this.shareUri,
required this.onBridgeMessage,
required this.onNavigationUrl,
required this.buildHookScript,
required this.buildClickScript,
});
@override
State<_OfficialDownloadDesktopWebView> createState() =>
_OfficialDownloadDesktopWebViewState();
}
class _OfficialDownloadDesktopWebViewState
extends State<_OfficialDownloadDesktopWebView> {
desktop.InAppWebViewController? _controller;
@override
void dispose() {
_controller?.dispose();
super.dispose();
}
Future<void> _installHook() async {
final controller = _controller;
if (controller == null) return;
try {
await controller.evaluateJavascript(source: widget.buildHookScript);
await controller.evaluateJavascript(source: widget.buildClickScript);
} catch (_) {
// The parent dialog timeout keeps the manual browser fallback available.
}
}
@override
Widget build(BuildContext context) {
return desktop.InAppWebView(
initialUrlRequest: desktop.URLRequest(
url: desktop.WebUri.uri(widget.shareUri),
),
initialSettings: desktop.InAppWebViewSettings(
javaScriptEnabled: true,
domStorageEnabled: true,
supportZoom: false,
transparentBackground: true,
useShouldOverrideUrlLoading: true,
useOnDownloadStart: true,
),
onWebViewCreated: (controller) {
_controller = controller;
controller.addJavaScriptHandler(
handlerName: 'CloudreveDownloadBridge',
callback: (args) {
if (args.isNotEmpty) {
widget.onBridgeMessage(args.first.toString());
}
},
);
},
onLoadStop: (_, url) => _installHook(),
shouldOverrideUrlLoading: (_, action) {
final url = action.request.url?.toString();
if (url != null && widget.onNavigationUrl(url)) {
return desktop.NavigationActionPolicy.CANCEL;
}
return desktop.NavigationActionPolicy.ALLOW;
},
onUpdateVisitedHistory: (_, url, isReload) {
if (url != null) {
widget.onNavigationUrl(url.toString());
}
},
onDownloadStarting: (_, request) {
widget.onNavigationUrl(request.url.toString());
return null;
},
);
}
}
class _ShareFileTile extends StatelessWidget {
final ShareLinkFile file;
final VoidCallback? onTap;
final VoidCallback? onDownload;
final VoidCallback? onSave;
const _ShareFileTile({
required this.file,
this.onTap,
this.onDownload,
this.onSave,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final icon = file.isFolder ? LucideIcons.folder : _iconForFile(file.name);
return Card(
elevation: 0,
margin: const EdgeInsets.only(bottom: 10),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: ListTile(
onTap: onTap,
leading: Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: file.isFolder
? theme.colorScheme.primaryContainer
: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(13),
),
child: Icon(
icon,
color: file.isFolder
? theme.colorScheme.onPrimaryContainer
: theme.colorScheme.primary,
),
),
title: Text(
file.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w700),
),
subtitle: Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
file.isFolder
? '文件夹'
: '${formatSize(file.size)}${file.updatedAt == null ? '' : ' · ${_formatDate(file.updatedAt!)}'}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
trailing: file.isFolder
? const Icon(Icons.chevron_right)
: Wrap(
spacing: 2,
children: [
IconButton(
tooltip: '转存',
icon: const Icon(Icons.drive_folder_upload_outlined),
onPressed: onSave,
),
IconButton(
tooltip: '下载',
icon: const Icon(LucideIcons.download),
onPressed: onDownload,
),
],
),
),
);
}
static IconData _iconForFile(String name) {
final lower = name.toLowerCase();
if (RegExp(r'\.(png|jpg|jpeg|gif|webp|bmp|heic)$').hasMatch(lower)) {
return LucideIcons.image;
}
if (RegExp(r'\.(mp4|mkv|mov|avi|webm|flv)$').hasMatch(lower)) {
return LucideIcons.video;
}
if (RegExp(r'\.(mp3|wav|flac|aac|ogg|m4a)$').hasMatch(lower)) {
return LucideIcons.music;
}
if (RegExp(r'\.(zip|rar|7z|tar|gz)$').hasMatch(lower)) {
return LucideIcons.archive;
}
if (RegExp(r'\.(pdf|doc|docx|xls|xlsx|ppt|pptx|txt|md)$').hasMatch(lower)) {
return LucideIcons.fileText;
}
return LucideIcons.file;
}
static String formatSize(int bytes) {
if (bytes <= 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
var size = bytes.toDouble();
var unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
final text = unitIndex == 0
? size.toStringAsFixed(0)
: size.toStringAsFixed(1);
return '$text ${units[unitIndex]}';
}
static String _formatDate(DateTime value) {
final local = value.toLocal();
return '${local.year}-${local.month.toString().padLeft(2, '0')}-${local.day.toString().padLeft(2, '0')}';
}
}
class _MetaChip extends StatelessWidget {
final IconData icon;
final String text;
const _MetaChip({required this.icon, required this.text});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 13, color: theme.hintColor),
const SizedBox(width: 5),
Text(text, style: theme.textTheme.labelSmall),
],
),
);
}
}
class _StatusBadge extends StatelessWidget {
final String text;
final Color color;
const _StatusBadge({required this.text, required this.color});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(999),
),
child: Text(
text,
style: TextStyle(
color: color,
fontWeight: FontWeight.w800,
fontSize: 12,
),
),
);
}
}
class _ErrorBox extends StatelessWidget {
final String text;
final String? actionText;
final VoidCallback? onAction;
const _ErrorBox({required this.text, this.actionText, this.onAction});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.errorContainer,
borderRadius: BorderRadius.circular(14),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
text,
style: TextStyle(color: theme.colorScheme.onErrorContainer),
),
if (actionText != null && onAction != null) ...[
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: TextButton(onPressed: onAction, child: Text(actionText!)),
),
],
],
),
);
}
}
class _EmptyState extends StatelessWidget {
final IconData icon;
final String title;
final String subtitle;
final String? actionText;
final VoidCallback? onAction;
const _EmptyState({
required this.icon,
required this.title,
required this.subtitle,
this.actionText,
this.onAction,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 56),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 46, color: theme.hintColor),
const SizedBox(height: 12),
Text(
title,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 6),
Text(
subtitle,
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor),
),
if (actionText != null && onAction != null) ...[
const SizedBox(height: 14),
OutlinedButton(onPressed: onAction, child: Text(actionText!)),
],
],
),
);
}
}