352 lines
9.8 KiB
Dart
352 lines
9.8 KiB
Dart
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:webview_flutter/webview_flutter.dart' as mobile;
|
|
|
|
import '../../../data/models/file_model.dart';
|
|
import '../../../services/api_service.dart';
|
|
|
|
bool get _useDesktopWebView =>
|
|
!kIsWeb && (Platform.isWindows || Platform.isLinux);
|
|
|
|
/// 完全交给 Cloudreve 官方 Web 前端处理文件打开。
|
|
///
|
|
/// 这里不再自己读取 file_viewers,也不再自己创建 viewerSession。
|
|
/// Cloudreve 前端本身会根据 `/home?path=...&open=...` 打开对应文件,
|
|
/// 并使用它自己的文件应用、WOPI、Markdown、表格、压缩包、EPUB 等逻辑。
|
|
class CloudreveFileAppPage extends StatefulWidget {
|
|
final FileModel file;
|
|
final String? preferredAction;
|
|
|
|
const CloudreveFileAppPage({
|
|
super.key,
|
|
required this.file,
|
|
this.preferredAction,
|
|
});
|
|
|
|
@override
|
|
State<CloudreveFileAppPage> createState() => _CloudreveFileAppPageState();
|
|
}
|
|
|
|
class _CloudreveFileAppPageState extends State<CloudreveFileAppPage> {
|
|
mobile.WebViewController? _mobileController;
|
|
desktop.InAppWebViewController? _desktopController;
|
|
|
|
bool _isPreparing = true;
|
|
bool _sessionInjected = false;
|
|
int _progress = 0;
|
|
String? _error;
|
|
late Uri _targetUri;
|
|
late Uri _originUri;
|
|
String? _sessionStateJson;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
|
|
if (!_useDesktopWebView) {
|
|
_mobileController = mobile.WebViewController()
|
|
..setJavaScriptMode(mobile.JavaScriptMode.unrestricted)
|
|
..setBackgroundColor(Colors.transparent)
|
|
..setNavigationDelegate(
|
|
mobile.NavigationDelegate(
|
|
onProgress: (progress) {
|
|
if (!mounted) return;
|
|
setState(() => _progress = progress);
|
|
},
|
|
onPageFinished: (_) => _injectSessionAndOpenIfNeeded(),
|
|
onWebResourceError: (error) {
|
|
if (!mounted) return;
|
|
if (error.isForMainFrame == true) {
|
|
setState(() {
|
|
_error = '${error.errorCode}: ${error.description}';
|
|
});
|
|
}
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
_prepareAndOpen();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_desktopController?.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _prepareAndOpen() async {
|
|
setState(() {
|
|
_isPreparing = true;
|
|
_error = null;
|
|
_progress = 0;
|
|
_sessionInjected = false;
|
|
if (_useDesktopWebView) {
|
|
_desktopController = null;
|
|
}
|
|
});
|
|
|
|
try {
|
|
_originUri = _buildOriginUri();
|
|
_targetUri = _buildCloudreveHomeUri();
|
|
|
|
_sessionStateJson = await _buildCloudreveFrontendSessionJson();
|
|
|
|
// 先打开同源页面,再注入 localStorage。
|
|
// 这样 Cloudreve 官方前端可以直接复用 App 的 access_token。
|
|
await _loadOrigin();
|
|
|
|
if (!mounted) return;
|
|
setState(() => _isPreparing = false);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_error = e.toString();
|
|
_isPreparing = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
Uri _buildOriginUri() {
|
|
final base = Uri.parse(ApiService.instance.dio.options.baseUrl);
|
|
return Uri(
|
|
scheme: base.scheme,
|
|
host: base.host,
|
|
port: base.hasPort ? base.port : null,
|
|
path: '/',
|
|
);
|
|
}
|
|
|
|
Uri _buildCloudreveHomeUri() {
|
|
final parent = _parentUri(widget.file.relativePath);
|
|
final openTarget = widget.file.id.isNotEmpty
|
|
? widget.file.id
|
|
: widget.file.relativePath;
|
|
|
|
return Uri(
|
|
scheme: _originUri.scheme,
|
|
host: _originUri.host,
|
|
port: _originUri.hasPort ? _originUri.port : null,
|
|
path: '/home',
|
|
queryParameters: {
|
|
'path': parent,
|
|
'open': openTarget,
|
|
'size': widget.file.size.toString(),
|
|
},
|
|
);
|
|
}
|
|
|
|
String _parentUri(String uri) {
|
|
final normalized = uri.endsWith('/')
|
|
? uri.substring(0, uri.length - 1)
|
|
: uri;
|
|
final index = normalized.lastIndexOf('/');
|
|
if (index <= 'cloudreve://my'.length) {
|
|
return 'cloudreve://my';
|
|
}
|
|
return normalized.substring(0, index);
|
|
}
|
|
|
|
Future<String?> _buildCloudreveFrontendSessionJson() async {
|
|
final tokenGetter = ApiService.instance.getTokenCallback;
|
|
final token = tokenGetter == null ? null : await tokenGetter();
|
|
if (token == null || token.isEmpty) {
|
|
return null;
|
|
}
|
|
|
|
Map<String, dynamic>? user;
|
|
try {
|
|
final response = await ApiService.instance.get<Map<String, dynamic>>(
|
|
'/user/me',
|
|
);
|
|
user = Map<String, dynamic>.from(response);
|
|
} catch (_) {
|
|
user = null;
|
|
}
|
|
|
|
final userId = user?['id']?.toString() ?? user?['uid']?.toString() ?? 'app';
|
|
|
|
final now = DateTime.now().toUtc();
|
|
final accessExpires = now.add(const Duration(hours: 2)).toIso8601String();
|
|
final refreshExpires = now.add(const Duration(hours: 2)).toIso8601String();
|
|
|
|
final sessionState = {
|
|
'current': userId,
|
|
'sessions': {
|
|
userId: {
|
|
'user': user ?? {'id': userId},
|
|
'token': {
|
|
'access_token': token,
|
|
// App 侧暂时没有把 refresh token 暴露给这里。
|
|
// 给 Cloudreve 前端一个短期可用 session;过期后 WebView 内刷新会要求重新登录。
|
|
'refresh_token': '',
|
|
'access_expires': accessExpires,
|
|
'refresh_expires': refreshExpires,
|
|
},
|
|
'settings': {},
|
|
},
|
|
},
|
|
'anonymousSettings': {},
|
|
};
|
|
|
|
return jsonEncode(sessionState);
|
|
}
|
|
|
|
Future<void> _injectSessionAndOpenIfNeeded() async {
|
|
if (_sessionInjected) return;
|
|
_sessionInjected = true;
|
|
|
|
final sessionJson = _sessionStateJson;
|
|
final target = _targetUri.toString();
|
|
|
|
try {
|
|
if (sessionJson != null) {
|
|
final script =
|
|
'''
|
|
try {
|
|
localStorage.setItem('cloudreve_session', ${jsonEncode(sessionJson)});
|
|
} catch (e) {}
|
|
window.location.replace(${jsonEncode(target)});
|
|
''';
|
|
await _runJavaScript(script);
|
|
} else {
|
|
await _loadTarget();
|
|
}
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
setState(() => _error = e.toString());
|
|
}
|
|
}
|
|
|
|
Future<void> _openTargetAgain() async {
|
|
_sessionInjected = true;
|
|
await _loadTarget();
|
|
}
|
|
|
|
Future<void> _reloadWebView() async {
|
|
if (_useDesktopWebView) {
|
|
await _desktopController?.reload();
|
|
} else {
|
|
await _mobileController?.reload();
|
|
}
|
|
}
|
|
|
|
Future<void> _loadOrigin() async {
|
|
if (_useDesktopWebView) {
|
|
final controller = _desktopController;
|
|
if (controller == null) return;
|
|
await _desktopController?.loadUrl(
|
|
urlRequest: desktop.URLRequest(url: desktop.WebUri.uri(_originUri)),
|
|
);
|
|
} else {
|
|
await _mobileController!.loadRequest(_originUri);
|
|
}
|
|
}
|
|
|
|
Future<void> _loadTarget() async {
|
|
if (_useDesktopWebView) {
|
|
await _desktopController?.loadUrl(
|
|
urlRequest: desktop.URLRequest(url: desktop.WebUri.uri(_targetUri)),
|
|
);
|
|
} else {
|
|
await _mobileController!.loadRequest(_targetUri);
|
|
}
|
|
}
|
|
|
|
Future<void> _runJavaScript(String source) async {
|
|
if (_useDesktopWebView) {
|
|
await _desktopController?.evaluateJavascript(source: source);
|
|
} else {
|
|
await _mobileController!.runJavaScript(source);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Text(widget.file.name, overflow: TextOverflow.ellipsis),
|
|
actions: [
|
|
IconButton(
|
|
tooltip: '重新打开',
|
|
icon: const Icon(Icons.open_in_browser),
|
|
onPressed: _openTargetAgain,
|
|
),
|
|
IconButton(
|
|
tooltip: '刷新',
|
|
icon: const Icon(Icons.refresh),
|
|
onPressed: _reloadWebView,
|
|
),
|
|
],
|
|
bottom: PreferredSize(
|
|
preferredSize: const Size.fromHeight(3),
|
|
child: _progress > 0 && _progress < 100
|
|
? LinearProgressIndicator(value: _progress / 100)
|
|
: const SizedBox(height: 3),
|
|
),
|
|
),
|
|
body: _buildBody(),
|
|
);
|
|
}
|
|
|
|
Widget _buildBody() {
|
|
if (_isPreparing) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
|
|
if (_error != null) {
|
|
return Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Icon(Icons.error_outline, size: 56, color: Colors.red),
|
|
const SizedBox(height: 16),
|
|
Text(_error!, textAlign: TextAlign.center),
|
|
const SizedBox(height: 16),
|
|
FilledButton.icon(
|
|
onPressed: _prepareAndOpen,
|
|
icon: const Icon(Icons.refresh),
|
|
label: const Text('重试'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
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!);
|
|
}
|
|
}
|