diff --git a/lib/api/client.dart b/lib/api/client.dart index b0e01f8..7d4d5fb 100644 --- a/lib/api/client.dart +++ b/lib/api/client.dart @@ -271,6 +271,19 @@ class EHApi extends __EHApi { return newUri.toString(); } + Uri getTaskUrl() { + final uri = Uri.parse(_combineBaseUrls(_dio.options.baseUrl, baseUrl)); + final nuri = uri.resolve("task"); + return Uri( + scheme: uri.scheme == "https" ? "wss" : "ws", + userInfo: nuri.userInfo, + host: nuri.host, + port: nuri.port, + path: nuri.path, + query: nuri.query, + ); + } + String getThumbnailUrl(int id, {int? max, int? width, diff --git a/lib/api/task.dart b/lib/api/task.dart index a1142dc..4ec4706 100644 --- a/lib/api/task.dart +++ b/lib/api/task.dart @@ -138,11 +138,11 @@ class TaskProgress { const TaskProgress({ required this.type, required this.taskId, - required this.progress, + required this.detail, }); final TaskType type; final int taskId; - final TaskProgressBasicType progress; + final TaskProgressBasicType detail; factory TaskProgress.fromJson(Map json) { final type = json['type'] as int; final taskId = json['task_id'] as int; @@ -151,29 +151,29 @@ class TaskProgress { return TaskProgress( type: TaskType.download, taskId: taskId, - progress: TaskDownloadProgess.fromJson( - json['progress'] as Map), + detail: TaskDownloadProgess.fromJson( + json['detail'] as Map), ); case 1: return TaskProgress( type: TaskType.exportZip, taskId: taskId, - progress: TaskExportZipProgress.fromJson( - json['progress'] as Map), + detail: TaskExportZipProgress.fromJson( + json['detail'] as Map), ); case 2: return TaskProgress( type: TaskType.updateMeiliSearchData, taskId: taskId, - progress: TaskUpdateMeiliSearchDataProgress.fromJson( - json['progress'] as Map), + detail: TaskUpdateMeiliSearchDataProgress.fromJson( + json['detail'] as Map), ); case 3: return TaskProgress( type: TaskType.fixGalleryPage, taskId: taskId, - progress: TaskFixGalleryPageProgress.fromJson( - json['progress'] as Map), + detail: TaskFixGalleryPageProgress.fromJson( + json['detail'] as Map), ); default: throw ArgumentError.value(type, 'type', 'Invalid task type'); @@ -200,9 +200,37 @@ class TaskDetail { this.error, this.fataled, }); - final Task base; + Task base; TaskProgressBasicType? progress; TaskStatus status; String? error; bool? fataled; } + +@JsonSerializable() +class TaskList { + const TaskList({ + required this.tasks, + required this.running, + }); + final List tasks; + final List running; + factory TaskList.fromJson(Map json) => + _$TaskListFromJson(json); + Map toJson() => _$TaskListToJson(this); +} + +@JsonSerializable() +class TaskError { + const TaskError({ + required this.task, + required this.error, + required this.fatal, + }); + final Task task; + final String error; + final bool fatal; + factory TaskError.fromJson(Map json) => + _$TaskErrorFromJson(json); + Map toJson() => _$TaskErrorToJson(this); +} diff --git a/lib/api/task.g.dart b/lib/api/task.g.dart index f16eb98..20585c1 100644 --- a/lib/api/task.g.dart +++ b/lib/api/task.g.dart @@ -120,3 +120,27 @@ Map _$TaskFixGalleryPageProgressToJson( 'total_gallery': instance.totalGallery, 'checked_gallery': instance.checkedGallery, }; + +TaskList _$TaskListFromJson(Map json) => TaskList( + tasks: (json['tasks'] as List) + .map((e) => Task.fromJson(e as Map)) + .toList(), + running: (json['running'] as List).map((e) => e as int).toList(), + ); + +Map _$TaskListToJson(TaskList instance) => { + 'tasks': instance.tasks, + 'running': instance.running, + }; + +TaskError _$TaskErrorFromJson(Map json) => TaskError( + task: Task.fromJson(json['task'] as Map), + error: json['error'] as String, + fatal: json['fatal'] as bool, + ); + +Map _$TaskErrorToJson(TaskError instance) => { + 'task': instance.task, + 'error': instance.error, + 'fatal': instance.fatal, + }; diff --git a/lib/auth.dart b/lib/auth.dart index 9112311..0801f57 100644 --- a/lib/auth.dart +++ b/lib/auth.dart @@ -88,6 +88,9 @@ class AuthInfo { _log.info( "Logged in as ${u.username} (${u.id}). isAdmin: ${u.isAdmin}. permissions: ${u.permissions}"); await checkSessionInfo(); + if (canManageTasks == true) { + await tasks.connect(); + } } else if (re.status == 401 || re.status == 1 || re.status == 404) { _user = null; } else { diff --git a/lib/globals.dart b/lib/globals.dart index ac3d52e..0567720 100644 --- a/lib/globals.dart +++ b/lib/globals.dart @@ -37,9 +37,11 @@ final dio = Dio() ..options.extra['withCredentials'] = true; Config? _prefs; EHApi? _api; +PersistCookieJar? _jar; Future prepareJar() async { final jar = PersistCookieJar(storage: FileStorage(await getJarPath())); + _jar = jar; dio.interceptors.add(CookieManager(jar)); } @@ -104,6 +106,10 @@ EHApi get api { return _api!; } +PersistCookieJar? get cookieJar { + return _jar; +} + final AuthInfo auth = AuthInfo(); final Clipboard platformClipboard = Clipboard(); final Display platformDisplay = Display(); @@ -121,6 +127,7 @@ enum MoreVertSettings { markAsNsfw, markAsSfw, serverSettings, + taskManager, } void onMoreVertSettingsSelected(BuildContext context, MoreVertSettings value) { @@ -143,6 +150,9 @@ void onMoreVertSettingsSelected(BuildContext context, MoreVertSettings value) { case MoreVertSettings.serverSettings: context.push("/server_settings"); break; + case MoreVertSettings.taskManager: + context.push("/task_manager"); + break; default: break; } @@ -176,6 +186,11 @@ List> buildMoreVertSettings( value: MoreVertSettings.serverSettings, child: Text(AppLocalizations.of(context)!.serverSettings))); } + if (path != "/task_manager" && auth.canManageTasks == true) { + list.add(PopupMenuItem( + value: MoreVertSettings.taskManager, + child: Text(AppLocalizations.of(context)!.taskManager))); + } var showNsfw = prefs.getBool("showNsfw") ?? false; list.add(PopupMenuItem( child: StatefulBuilder( diff --git a/lib/home.dart b/lib/home.dart index c4d41a1..8494e8a 100644 --- a/lib/home.dart +++ b/lib/home.dart @@ -42,6 +42,16 @@ class HomeDrawer extends StatelessWidget { }, ) : Container(), + auth.canManageTasks == true + ? ListTile( + leading: const Icon(Icons.task), + title: Text(AppLocalizations.of(context)!.taskManager), + onTap: () { + Scaffold.of(context).closeDrawer(); + context.push("/task_manager"); + }, + ) + : Container(), ListTile( leading: const Icon(Icons.settings), title: Text(AppLocalizations.of(context)!.settings), diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 87b9dd9..66f8c1e 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -143,5 +143,11 @@ "redirectToFlutter": "Redirect to Flutter frontend when accessing the root URL.", "downloadTimeoutCheckInterval": "The interval of checking download timeout", "downloadTimeoutCheckIntervalHelp": "The smaller the value, the more accurate the timeout detection, but the higher CPU usage.", - "dockerHelper": "The server is running in a Docker container. Unless you know what you are doing, do not change this setting." + "dockerHelper": "The server is running in a Docker container. Unless you know what you are doing, do not change this setting.", + "taskManager": "Task Manager", + "waiting": "Waiting", + "running": "Running", + "finished": "Finished", + "failed": "Failed", + "allTasks": "All Tasks" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index e6b4625..9a010e5 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -143,5 +143,11 @@ "redirectToFlutter": "访问根 URL 时重定向到 flutter 前端。", "downloadTimeoutCheckInterval": "下载超时检测间隔", "downloadTimeoutCheckIntervalHelp": "值越小,检测准确性越高,但是消耗更多的 CPU。", - "dockerHelper": "服务器运行在 Docker 容器中。除非你知道你在做什么,否则不要修改这个设置。" + "dockerHelper": "服务器运行在 Docker 容器中。除非你知道你在做什么,否则不要修改这个设置。", + "taskManager": "任务管理器", + "waiting": "等待中", + "running": "运行中", + "finished": "已完成", + "failed": "已失败", + "allTasks": "所有任务" } diff --git a/lib/main.dart b/lib/main.dart index 5c026e2..1f0af75 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -20,6 +20,7 @@ import 'logs/file.dart'; import 'server_settings.dart'; import 'set_server.dart'; import 'settings.dart'; +import 'task_manager.dart'; import 'utils.dart'; import 'viewer/single.dart'; @@ -159,8 +160,12 @@ final _router = GoRouter( }), GoRoute( path: ServerSettingsPage.routeName, - builder: (context, state) => const ServerSettingsPage(), - ) + builder: (context, state) => ServerSettingsPage(key: state.pageKey), + ), + GoRoute( + path: TaskManagerPage.routeName, + builder: (context, state) => TaskManagerPage(key: state.pageKey), + ), ], ); @@ -225,13 +230,15 @@ class MainApp extends StatefulWidget { context.findAncestorStateOfType<_MainApp>()!; } -class _MainApp extends State { +class _MainApp extends State with WidgetsBindingObserver { ThemeMode _themeMode = ThemeMode.system; ThemeData _themeData = ThemeData(useMaterial3: true); ThemeData _darkThemeData = ThemeData.dark(useMaterial3: true); ThemeMode get themeMode => _themeMode; Lang _lang = Lang.system; Lang get lang => _lang; + AppLifecycleState? _lifecycleState; + AppLifecycleState? get lifecycleState => _lifecycleState; @override void initState() { @@ -250,6 +257,23 @@ class _MainApp extends State { _themeData = _themeData.useSystemChineseFont(Brightness.light); _darkThemeData = _darkThemeData.useSystemChineseFont(Brightness.dark); } + WidgetsBinding.instance.addObserver(this); + if (WidgetsBinding.instance.lifecycleState != null) { + _lifecycleState = WidgetsBinding.instance.lifecycleState; + listener.tryEmit("lifecycle", _lifecycleState); + } + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + _lifecycleState = state; + listener.tryEmit("lifecycle", _lifecycleState); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); } @override diff --git a/lib/task.dart b/lib/task.dart index be647b2..8ba90d3 100644 --- a/lib/task.dart +++ b/lib/task.dart @@ -1,8 +1,127 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:logging/logging.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; import 'api/task.dart'; +import 'globals.dart'; +import 'utils/websocket.dart'; + +final _log = Logger("TaskManager"); class TaskManager { Map tasks = {}; + WebSocketChannel? _channel; + bool _closed = false; + bool _allowReconnect = true; + Timer? _reconnectTimer; void clear() { tasks.clear(); + _channel?.stream.drain(); + _channel?.sink.close(); + _closed = true; + } + + Future connect() async { + if (auth.canManageTasks != true) return; + try { + _channel = await connectWebSocket(api.getTaskUrl()); + _channel!.stream.listen((event) { + try { + final data = jsonDecode(event) as Map; + final type = data["type"] as String; + if (type == "tasks") { + final list = TaskList.fromJson(data); + for (var task in list.tasks) { + tasks[task.id] = TaskDetail( + base: task, + status: list.running.contains(task.id) + ? TaskStatus.running + : TaskStatus.wait, + ); + } + } else if (type == "new_task") { + final task = Task.fromJson(data["detail"] as Map); + tasks[task.id] = TaskDetail( + base: task, + status: TaskStatus.wait, + ); + } else if (type == "task_started") { + final task = Task.fromJson(data["detail"] as Map); + tasks.update(task.id, (value) { + value.status = TaskStatus.running; + return value; + }, + ifAbsent: () => TaskDetail( + base: task, + status: TaskStatus.running, + )); + } else if (type == "task_finished") { + final task = Task.fromJson(data["detail"] as Map); + if (tasks.containsKey(task.id)) { + tasks.update(task.id, (value) { + value.status = TaskStatus.finished; + return value; + }); + } + } else if (type == "task_progress") { + final task = + TaskProgress.fromJson(data["detail"] as Map); + if (tasks.containsKey(task.taskId)) { + tasks.update(task.taskId, (value) { + value.progress = task.detail; + return value; + }); + } + } else if (type == "task_updated") { + final task = Task.fromJson(data["detail"] as Map); + if (tasks.containsKey(task.id)) { + tasks.update(task.id, (value) { + value.base = task; + return value; + }); + } + } else if (type == "task_error") { + final info = + TaskError.fromJson(data["detail"] as Map); + if (tasks.containsKey(info.task.id)) { + tasks.update(info.task.id, (value) { + value.status = TaskStatus.failed; + value.error = info.error; + value.fataled = info.fatal; + return value; + }); + } + } + } catch (e) { + _log.warning("Error processing task message: $e"); + } + }, onError: (e) { + _log.warning("Task websocket error: $e"); + if (_allowReconnect) { + _log.info("Reconnecting to task server in 5 seconds"); + _reconnectTimer = Timer(const Duration(seconds: 5), () { + _reconnectTimer = null; + connect(); + }); + } + }, cancelOnError: true); + await _channel!.ready; + _closed = false; + sendTaskList(); + } catch (e) { + _channel = null; + _log.warning("Failed to connect to task server: $e"); + if (_allowReconnect) { + _log.info("Reconnecting to task server in 5 seconds"); + _reconnectTimer = Timer(const Duration(seconds: 5), () { + _reconnectTimer = null; + connect(); + }); + } + } + } + + void sendTaskList() { + _channel?.sink.add("{\"type\":\"task_list\"}"); } } diff --git a/lib/task_manager.dart b/lib/task_manager.dart new file mode 100644 index 0000000..32bac4e --- /dev/null +++ b/lib/task_manager.dart @@ -0,0 +1,129 @@ +import 'package:enum_flag/enum_flag.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:go_router/go_router.dart'; +import 'globals.dart'; + +enum TaskStatusFilterFlag with EnumFlag { + wait, + running, + finished, + failed; + + String localText(BuildContext context) { + final i18n = AppLocalizations.of(context)!; + switch (this) { + case TaskStatusFilterFlag.wait: + return i18n.waiting; + case TaskStatusFilterFlag.running: + return i18n.running; + case TaskStatusFilterFlag.finished: + return i18n.finished; + case TaskStatusFilterFlag.failed: + return i18n.failed; + } + } +} + +const taskStatusFilterFlagAll = 15; + +class TaskStatusFilter { + TaskStatusFilter({this.code = taskStatusFilterFlagAll}); + int code; + bool has(TaskStatusFilterFlag flag) => code.hasFlag(flag); + bool get isAll => code == taskStatusFilterFlagAll; + void add(TaskStatusFilterFlag flag) { + code |= flag.value; + } + + void remove(TaskStatusFilterFlag flag) { + code &= ~flag.value; + } +} + +class TaskManagerPage extends StatefulWidget { + const TaskManagerPage({super.key}); + + static const String routeName = '/task_manager'; + + @override + State createState() => _TaskManagerPage(); +} + +class _TaskManagerPage extends State + with ThemeModeWidget, IsTopWidget2 { + late TaskStatusFilter _filter; + @override + void initState() { + _filter = TaskStatusFilter(); + super.initState(); + } + + Widget _buildChips() { + final i18n = AppLocalizations.of(context)!; + var list = [ + FilterChip( + label: Text(i18n.allTasks), + selected: _filter.isAll, + onSelected: (bool value) { + setState(() { + if (value) { + _filter.code = taskStatusFilterFlagAll; + } else { + _filter.code = 0; + } + }); + }, + ) + ]; + for (var flag in TaskStatusFilterFlag.values) { + list.add(FilterChip( + label: Text(flag.localText(context)), + selected: _filter.has(flag), + onSelected: (bool value) { + setState(() { + if (value) { + _filter.add(flag); + } else { + _filter.remove(flag); + } + }); + }, + )); + } + return SliverToBoxAdapter( + child: Wrap( + spacing: 5.0, + children: list, + )); + } + + @override + Widget build(BuildContext context) { + final i18n = AppLocalizations.of(context)!; + if (isTop(context)) { + setCurrentTitle(i18n.taskManager, Theme.of(context).primaryColor.value); + } + return Scaffold( + body: CustomScrollView( + slivers: [ + SliverAppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + context.canPop() ? context.pop() : context.go("/"); + }, + ), + title: Text(i18n.taskManager), + actions: [ + buildThemeModeIcon(context), + buildMoreVertSettingsButon(context), + ], + floating: true, + ), + _buildChips(), + ], + ), + ); + } +} diff --git a/lib/utils/websocket.dart b/lib/utils/websocket.dart new file mode 100644 index 0000000..70b8431 --- /dev/null +++ b/lib/utils/websocket.dart @@ -0,0 +1 @@ +export './websocket_io.dart' if (dart.library.html) './websocket_web.dart'; diff --git a/lib/utils/websocket_io.dart b/lib/utils/websocket_io.dart new file mode 100644 index 0000000..98f2ad8 --- /dev/null +++ b/lib/utils/websocket_io.dart @@ -0,0 +1,25 @@ +import 'package:web_socket_channel/io.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; +import '../globals.dart'; + +Future connectWebSocket(Uri uri) async { + final Map headers = {}; + final jar = cookieJar; + if (jar != null) { + final nuri =Uri( + scheme: uri.scheme == "wss" ? "https" : "http", + userInfo: uri.userInfo, + host: uri.host, + port: uri.port, + path: uri.path, + query: uri.query, + ); + final cookies = await jar.loadForRequest(nuri); + final list = []; + for (var cookie in cookies) { + list.add('${cookie.name}=${cookie.value}'); + } + headers['cookie'] = list.join('; '); + } + return IOWebSocketChannel.connect(uri, headers: headers); +} diff --git a/lib/utils/websocket_web.dart b/lib/utils/websocket_web.dart new file mode 100644 index 0000000..22190f9 --- /dev/null +++ b/lib/utils/websocket_web.dart @@ -0,0 +1,6 @@ +import 'package:web_socket_channel/html.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +Future connectWebSocket(Uri uri) async { + return HtmlWebSocketChannel.connect(uri, binaryType: BinaryType.list); +} diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 6ec329e..ef7e3e5 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -61,12 +61,12 @@ SPEC CHECKSUMS: FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 irondash_engine_context: da62996ee25616d2f01bbeb85dc115d813359478 package_info_plus: 02d7a575e80f194102bef286361c6c326e4c29ce - path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943 + path_provider_foundation: 3784922295ac71e43754bd15e0653ccfd36a147c screen_retriever: 59634572a57080243dd1bf715e55b6c54f241a38 - shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126 + shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695 super_native_extensions: 85efee3a7495b46b04befcfc86ed12069264ebf3 window_manager: 3a1844359a6295ab1e47659b1a777e36773cd6e8 PODFILE CHECKSUM: 236401fc2c932af29a9fcf0e97baeeb2d750d367 -COCOAPODS: 1.15.0 +COCOAPODS: 1.15.2 diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index 2521352..2d4f12e 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -258,7 +258,7 @@ isa = PBXProject; attributes = { LastSwiftUpdateCheck = 0920; - LastUpgradeCheck = 1430; + LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { 331C80D4294CF70F00263BE5 = { diff --git a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index b4c79f7..9305a0f 100644 --- a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,6 +1,6 @@