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